@sanctuary-framework/mcp-server 1.2.6 → 1.2.8

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/index.js CHANGED
@@ -4915,7 +4915,8 @@ var SIGNATURE_SCHEME_V1 = "ed25519-v1";
4915
4915
  var RESERVED_EVENT_TYPE_PREFIXES = [
4916
4916
  "EXTENSION_",
4917
4917
  "cross_fortress_",
4918
- "multi_master_"
4918
+ "multi_master_",
4919
+ "cross_harness_approval_"
4919
4920
  ];
4920
4921
  function isReservedEventType(s) {
4921
4922
  return RESERVED_EVENT_TYPE_PREFIXES.some((p) => s.startsWith(p));
@@ -9095,9 +9096,9 @@ function fingerprintDID(did) {
9095
9096
  return `${raw.slice(0, 6)}\u2026${raw.slice(-6)}`;
9096
9097
  }
9097
9098
  function countInjectionsToday(audit) {
9098
- const startOfDay = /* @__PURE__ */ new Date();
9099
- startOfDay.setHours(0, 0, 0, 0);
9100
- const cutoff = startOfDay.getTime();
9099
+ const startOfDay2 = /* @__PURE__ */ new Date();
9100
+ startOfDay2.setHours(0, 0, 0, 0);
9101
+ const cutoff = startOfDay2.getTime();
9101
9102
  return audit.filter((e) => {
9102
9103
  const ts = new Date(e.timestamp).getTime();
9103
9104
  if (isNaN(ts) || ts < cutoff) return false;
@@ -9111,9 +9112,9 @@ var PROOF_CREATION_OPS = /* @__PURE__ */ new Set([
9111
9112
  "proof_commitment"
9112
9113
  ]);
9113
9114
  function countProofsToday(audit) {
9114
- const startOfDay = /* @__PURE__ */ new Date();
9115
- startOfDay.setHours(0, 0, 0, 0);
9116
- const cutoff = startOfDay.getTime();
9115
+ const startOfDay2 = /* @__PURE__ */ new Date();
9116
+ startOfDay2.setHours(0, 0, 0, 0);
9117
+ const cutoff = startOfDay2.getTime();
9117
9118
  return audit.filter((e) => {
9118
9119
  if (e.layer !== "l3") return false;
9119
9120
  if (!PROOF_CREATION_OPS.has(e.operation)) return false;
@@ -16312,6 +16313,24 @@ async function handleApprovalInboxRoute(deps, req, res) {
16312
16313
  await handleStream2(deps, res);
16313
16314
  return true;
16314
16315
  }
16316
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/revision`) {
16317
+ const revision = await deps.aggregator.getRevision();
16318
+ writeJSON4(res, 200, { ok: true, data: { revision } });
16319
+ return true;
16320
+ }
16321
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/sync`) {
16322
+ const sinceRaw = url.searchParams.get("since_revision");
16323
+ const sinceParsed = sinceRaw === null ? 0 : Number.parseInt(sinceRaw, 10);
16324
+ const sinceRevision = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : 0;
16325
+ const limit = parseLimit2(
16326
+ url.searchParams.get("limit"),
16327
+ APPROVAL_INBOX_DEFAULT_LIMIT,
16328
+ APPROVAL_INBOX_MAX_LIMIT
16329
+ );
16330
+ const delta = await deps.aggregator.getSync({ sinceRevision, limit });
16331
+ writeJSON4(res, 200, { ok: true, data: delta });
16332
+ return true;
16333
+ }
16315
16334
  if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/history`) {
16316
16335
  const limit = parseLimit2(
16317
16336
  url.searchParams.get("limit"),
@@ -16427,6 +16446,119 @@ async function handleApprovalInboxRoute(deps, req, res) {
16427
16446
  }
16428
16447
  }
16429
16448
 
16449
+ // src/sentinel/sentinel-routes.ts
16450
+ var SENTINEL_API_PREFIX = "/api/sentinels";
16451
+ var FINDINGS_DEFAULT_LIMIT = 100;
16452
+ var FINDINGS_MAX_LIMIT = 500;
16453
+ function writeJSON5(res, status, payload) {
16454
+ res.writeHead(status, {
16455
+ "Content-Type": "application/json",
16456
+ "Cache-Control": "no-store"
16457
+ });
16458
+ res.end(JSON.stringify(payload));
16459
+ }
16460
+ function isSeverity(value) {
16461
+ return value === "info" || value === "warn" || value === "alert";
16462
+ }
16463
+ function parseLimit3(raw, defaultValue, max) {
16464
+ if (raw === null || raw === "") return defaultValue;
16465
+ const parsed = Number.parseInt(raw, 10);
16466
+ if (Number.isNaN(parsed) || parsed < 0) return defaultValue;
16467
+ return Math.min(parsed, max);
16468
+ }
16469
+ function matchSubscribeRoute(path) {
16470
+ const prefix = `${SENTINEL_API_PREFIX}/`;
16471
+ if (!path.startsWith(prefix)) return null;
16472
+ const rest = path.slice(prefix.length);
16473
+ if (!rest.endsWith("/subscribe")) return null;
16474
+ const sentinelId = rest.slice(0, rest.length - "/subscribe".length);
16475
+ if (sentinelId.length === 0) return null;
16476
+ return { sentinelId: decodeURIComponent(sentinelId) };
16477
+ }
16478
+ async function handleSentinelRoute(deps, req, res) {
16479
+ const host = req.headers.host || "localhost";
16480
+ const url = new URL(req.url ?? "/", `http://${host}`);
16481
+ const method = (req.method ?? "GET").toUpperCase();
16482
+ const path = url.pathname;
16483
+ if (path !== SENTINEL_API_PREFIX && !path.startsWith(`${SENTINEL_API_PREFIX}/`)) {
16484
+ return false;
16485
+ }
16486
+ const checkAuth = authMiddleware(deps.authConfig);
16487
+ if (!checkAuth(req, res, url)) return true;
16488
+ const dispatcher = deps.dispatcher;
16489
+ const registry = dispatcher.getRegistry();
16490
+ const findingStore = dispatcher.getFindingStore();
16491
+ try {
16492
+ if (method === "GET" && path === SENTINEL_API_PREFIX) {
16493
+ const catalog = registry.listCatalog();
16494
+ writeJSON5(res, 200, { ok: true, data: { catalog } });
16495
+ return true;
16496
+ }
16497
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/subscribed`) {
16498
+ const subscribed = registry.listSubscribed();
16499
+ writeJSON5(res, 200, { ok: true, data: { subscribed } });
16500
+ return true;
16501
+ }
16502
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/findings`) {
16503
+ const limit = parseLimit3(
16504
+ url.searchParams.get("limit"),
16505
+ FINDINGS_DEFAULT_LIMIT,
16506
+ FINDINGS_MAX_LIMIT
16507
+ );
16508
+ const since = url.searchParams.get("since") ?? void 0;
16509
+ const severityRaw = url.searchParams.get("severity") ?? void 0;
16510
+ const sentinelIdFilter = url.searchParams.get("sentinel_id") ?? void 0;
16511
+ const agentIdFilter = url.searchParams.get("agent_id") ?? void 0;
16512
+ const severity = severityRaw && isSeverity(severityRaw) ? severityRaw : void 0;
16513
+ const findings = await findingStore.listFindings({
16514
+ limit,
16515
+ ...since !== void 0 ? { since } : {},
16516
+ ...severity !== void 0 ? { severity } : {},
16517
+ ...sentinelIdFilter !== void 0 ? { sentinelId: sentinelIdFilter } : {},
16518
+ ...agentIdFilter !== void 0 ? { agentId: agentIdFilter } : {}
16519
+ });
16520
+ writeJSON5(res, 200, { ok: true, data: { findings } });
16521
+ return true;
16522
+ }
16523
+ const subscribeMatch = matchSubscribeRoute(path);
16524
+ if (subscribeMatch) {
16525
+ if (method === "POST") {
16526
+ try {
16527
+ await dispatcher.subscribeSentinel(subscribeMatch.sentinelId);
16528
+ writeJSON5(res, 200, {
16529
+ ok: true,
16530
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: true }
16531
+ });
16532
+ } catch (err) {
16533
+ const msg = err instanceof Error ? err.message : String(err);
16534
+ if (msg.startsWith("sentinel-registry: unknown sentinel")) {
16535
+ writeJSON5(res, 404, { ok: false, error: "not_found" });
16536
+ } else {
16537
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
16538
+ }
16539
+ }
16540
+ return true;
16541
+ }
16542
+ if (method === "DELETE") {
16543
+ const removed = await dispatcher.unsubscribeSentinel(
16544
+ subscribeMatch.sentinelId
16545
+ );
16546
+ writeJSON5(res, 200, {
16547
+ ok: true,
16548
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: false, removed }
16549
+ });
16550
+ return true;
16551
+ }
16552
+ }
16553
+ writeJSON5(res, 404, { ok: false, error: "not_found", path });
16554
+ return true;
16555
+ } catch (err) {
16556
+ const msg = err instanceof Error ? err.message : String(err);
16557
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
16558
+ return true;
16559
+ }
16560
+ }
16561
+
16430
16562
  // src/principal-policy/dashboard.ts
16431
16563
  var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16432
16564
  var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
@@ -16499,6 +16631,13 @@ var DashboardApprovalChannel = class {
16499
16631
  * the operator-facing query / decision surface.
16500
16632
  */
16501
16633
  approvalAggregator = null;
16634
+ /**
16635
+ * v1.3 WP-V1.3-1 Phi-1 Sentinel dispatcher. Mounted additively at
16636
+ * `/api/sentinels/*` when set. Sentinel surface is read-only against
16637
+ * the audit log; subscribe/unsubscribe writes flow through the
16638
+ * dispatcher's audited paths.
16639
+ */
16640
+ sentinelDispatcher = null;
16502
16641
  constructor(config) {
16503
16642
  this.config = config;
16504
16643
  this.authToken = config.auth_token;
@@ -16558,6 +16697,14 @@ var DashboardApprovalChannel = class {
16558
16697
  setApprovalAggregator(aggregator) {
16559
16698
  this.approvalAggregator = aggregator;
16560
16699
  }
16700
+ /**
16701
+ * v1.3 WP-V1.3-1 Phi-1: bind the Sentinel dispatcher. Once set,
16702
+ * requests to `/api/sentinels/*` route through `handleSentinelRoute`.
16703
+ * Pass `null` to detach (used by tests + during shutdown).
16704
+ */
16705
+ setSentinelDispatcher(dispatcher) {
16706
+ this.sentinelDispatcher = dispatcher;
16707
+ }
16561
16708
  /**
16562
16709
  * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
16563
16710
  * before the legacy approval route table. Returns true when served.
@@ -16577,6 +16724,25 @@ var DashboardApprovalChannel = class {
16577
16724
  res
16578
16725
  );
16579
16726
  }
16727
+ /**
16728
+ * v1.3 WP-V1.3-1 Phi-1 dispatch entry point. Routes `/api/sentinels/*`
16729
+ * requests through the sentinel router when a dispatcher has been
16730
+ * bound. Returns true when served.
16731
+ */
16732
+ async dispatchSentinel(req, res) {
16733
+ if (!this.sentinelDispatcher) return false;
16734
+ return handleSentinelRoute(
16735
+ {
16736
+ authConfig: {
16737
+ loopbackAutoAuth: this._autoAuthLocalhost,
16738
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
16739
+ },
16740
+ dispatcher: this.sentinelDispatcher
16741
+ },
16742
+ req,
16743
+ res
16744
+ );
16745
+ }
16580
16746
  /**
16581
16747
  * v1.1 dispatch entry point. Called from `handleRequest` before the
16582
16748
  * legacy route table. Returns true when the request was served by v1.1
@@ -16964,6 +17130,18 @@ var DashboardApprovalChannel = class {
16964
17130
  });
16965
17131
  return;
16966
17132
  }
17133
+ if (this.sentinelDispatcher && url.pathname.startsWith(SENTINEL_API_PREFIX)) {
17134
+ this.dispatchSentinel(req, res).then((handled) => {
17135
+ if (handled) return;
17136
+ this.handleLegacyRequest(req, res, url, method);
17137
+ }).catch(() => {
17138
+ if (!res.headersSent) {
17139
+ res.writeHead(500, { "Content-Type": "application/json" });
17140
+ res.end(JSON.stringify({ error: "Internal server error" }));
17141
+ }
17142
+ });
17143
+ return;
17144
+ }
16967
17145
  if (this.v11Bindings) {
16968
17146
  this.dispatchV11(req, res, url, method).then((handled) => {
16969
17147
  if (handled) return;
@@ -19407,6 +19585,20 @@ var ApprovalAggregator = class {
19407
19585
  hydrated = false;
19408
19586
  /** Active SSE listeners. */
19409
19587
  listeners = /* @__PURE__ */ new Set();
19588
+ /**
19589
+ * Monotonic revision counter, bumped on every mutation (ingest of new
19590
+ * entry, resolve, expire, delete). Hydrated from max(last_modified_revision)
19591
+ * across persisted entries on first read; in-memory after that. v1.3
19592
+ * Upsilon-4.
19593
+ */
19594
+ currentRevision = 0;
19595
+ /**
19596
+ * Removal tombstones: aggregator_id -> revision at removal. Used by the
19597
+ * sync API to surface "removed" entries to mobile consumers between
19598
+ * polls. In-memory only; server restart clears tombstones (mobile
19599
+ * bootstraps via `list()` on reconnect). v1.3 Upsilon-4.
19600
+ */
19601
+ removedTombstones = /* @__PURE__ */ new Map();
19410
19602
  constructor(deps) {
19411
19603
  this.storage = deps.storage;
19412
19604
  this.encryptionKey = derivePurposeKey(
@@ -19441,6 +19633,113 @@ var ApprovalAggregator = class {
19441
19633
  this.listeners.add(listener);
19442
19634
  return () => this.listeners.delete(listener);
19443
19635
  }
19636
+ /**
19637
+ * Current aggregator revision. v1.3 Upsilon-4. Mobile companions
19638
+ * poll the lightweight `/revision` route to detect that something
19639
+ * changed before fetching a full sync delta.
19640
+ */
19641
+ async getRevision() {
19642
+ await this.hydrate();
19643
+ return this.currentRevision;
19644
+ }
19645
+ /**
19646
+ * Compute a delta since `sinceRevision`. v1.3 Upsilon-4. Mobile
19647
+ * clients poll this for cheap state-sync. Behavior:
19648
+ * - `added`: entries whose `created_at_revision > sinceRevision`.
19649
+ * - `changed`: entries that existed at `sinceRevision` but had a
19650
+ * status transition (resolve, expire) since.
19651
+ * - `removed`: aggregator_ids deleted after `sinceRevision`.
19652
+ * - `revision`: current aggregator revision; pass this back as
19653
+ * `sinceRevision` on the next call.
19654
+ *
19655
+ * `limit` caps the total count returned across all three lists,
19656
+ * prioritized as added -> changed -> removed (newer-state first).
19657
+ * When more changes exist than fit, the next call with the returned
19658
+ * revision will pick up the rest because each entry's
19659
+ * last_modified_revision is unchanged by truncation.
19660
+ */
19661
+ async getSync(opts) {
19662
+ await this.hydrate();
19663
+ await this.expireStale();
19664
+ const sinceRevision = opts?.sinceRevision ?? 0;
19665
+ const cap = Math.min(
19666
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
19667
+ this.maxListLimit
19668
+ );
19669
+ const added = [];
19670
+ const changed = [];
19671
+ for (const entry of this.entries.values()) {
19672
+ const lastMod = entry.last_modified_revision ?? 0;
19673
+ if (lastMod <= sinceRevision) continue;
19674
+ const createdRev = entry.created_at_revision ?? 0;
19675
+ if (createdRev > sinceRevision) {
19676
+ added.push(entry);
19677
+ } else {
19678
+ changed.push(entry);
19679
+ }
19680
+ }
19681
+ const removed = [];
19682
+ for (const [id, rev] of this.removedTombstones) {
19683
+ if (rev > sinceRevision) removed.push(id);
19684
+ }
19685
+ added.sort(
19686
+ (a, b) => (a.last_modified_revision ?? 0) - (b.last_modified_revision ?? 0)
19687
+ );
19688
+ changed.sort(
19689
+ (a, b) => (a.last_modified_revision ?? 0) - (b.last_modified_revision ?? 0)
19690
+ );
19691
+ let remaining = cap;
19692
+ const addedOut = added.slice(0, Math.max(0, remaining));
19693
+ remaining -= addedOut.length;
19694
+ const changedOut = changed.slice(0, Math.max(0, remaining));
19695
+ remaining -= changedOut.length;
19696
+ const removedOut = removed.slice(0, Math.max(0, remaining));
19697
+ return {
19698
+ revision: this.currentRevision,
19699
+ added: addedOut,
19700
+ changed: changedOut,
19701
+ removed: removedOut
19702
+ };
19703
+ }
19704
+ /**
19705
+ * Delete an entry. Drops the in-memory record, the persisted bundle,
19706
+ * and the at-rest payload (if a payload store is wired). Records a
19707
+ * tombstone with the new revision so sync-API consumers see a
19708
+ * `removed` delta. Returns true when an entry was deleted, false on
19709
+ * unknown id. v1.3 Upsilon-4. Reserved for v1.4+ retention housekeeping;
19710
+ * Upsilon-4 ships the surface so mobile sync-API tests can exercise the
19711
+ * removal path.
19712
+ */
19713
+ async deleteEntry(aggregatorId) {
19714
+ await this.hydrate();
19715
+ const entry = this.entries.get(aggregatorId);
19716
+ if (!entry) return false;
19717
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
19718
+ this.entries.delete(aggregatorId);
19719
+ this.dedupIndex.delete(dedupKey);
19720
+ this.fullPayloads.delete(aggregatorId);
19721
+ for (const [corr, id] of this.correlationIndex) {
19722
+ if (id === aggregatorId) this.correlationIndex.delete(corr);
19723
+ }
19724
+ try {
19725
+ await this.storage.delete(APPROVAL_AGGREGATOR_NAMESPACE, aggregatorId);
19726
+ } catch {
19727
+ }
19728
+ if (this.payloadStore) {
19729
+ try {
19730
+ await this.payloadStore.deletePayload(aggregatorId);
19731
+ } catch {
19732
+ }
19733
+ }
19734
+ const revision = this.nextRevision();
19735
+ this.removedTombstones.set(aggregatorId, revision);
19736
+ this.emit({ type: "removed", entry: { ...entry } });
19737
+ return true;
19738
+ }
19739
+ nextRevision() {
19740
+ this.currentRevision += 1;
19741
+ return this.currentRevision;
19742
+ }
19444
19743
  /**
19445
19744
  * Ingest a gate event. Returns the aggregator entry on first sight,
19446
19745
  * `null` when deduped. Resolution events update the existing record;
@@ -19651,6 +19950,7 @@ var ApprovalAggregator = class {
19651
19950
  entry.status = decision;
19652
19951
  entry.resolved_at = this.now().toISOString();
19653
19952
  entry.resolved_by = operatorId;
19953
+ entry.last_modified_revision = this.nextRevision();
19654
19954
  await this.persist(entry);
19655
19955
  this.auditLog.append(
19656
19956
  "l2",
@@ -19702,6 +20002,7 @@ var ApprovalAggregator = class {
19702
20002
  const expires = new Date(now.getTime() + this.pendingTtlMs);
19703
20003
  const hubInboxId = this.resolveHubInboxItemId(event);
19704
20004
  const enforcementChain = this.resolveEnforcementChain(event);
20005
+ const revision = this.nextRevision();
19705
20006
  const entry = {
19706
20007
  aggregator_id: id,
19707
20008
  source_harness: ctx.source_harness,
@@ -19713,6 +20014,8 @@ var ApprovalAggregator = class {
19713
20014
  status: "pending",
19714
20015
  created_at: now.toISOString(),
19715
20016
  expires_at: expires.toISOString(),
20017
+ created_at_revision: revision,
20018
+ last_modified_revision: revision,
19716
20019
  ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {},
19717
20020
  ...enforcementChain.length > 0 ? { enforcement_chain: enforcementChain } : {}
19718
20021
  };
@@ -19755,6 +20058,7 @@ var ApprovalAggregator = class {
19755
20058
  entry.status = status;
19756
20059
  entry.resolved_at = event.resolution.decided_at;
19757
20060
  entry.resolved_by = event.resolution.decided_by;
20061
+ entry.last_modified_revision = this.nextRevision();
19758
20062
  await this.persist(entry);
19759
20063
  this.auditLog.append(
19760
20064
  "l2",
@@ -19817,6 +20121,7 @@ var ApprovalAggregator = class {
19817
20121
  entry.status = "expired";
19818
20122
  entry.resolved_at = this.now().toISOString();
19819
20123
  entry.resolved_by = "system_ttl";
20124
+ entry.last_modified_revision = this.nextRevision();
19820
20125
  await this.persist(entry);
19821
20126
  this.auditLog.append(
19822
20127
  "l2",
@@ -19863,6 +20168,10 @@ var ApprovalAggregator = class {
19863
20168
  this.entries.set(entry.aggregator_id, entry);
19864
20169
  const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
19865
20170
  this.dedupIndex.set(dedupKey, entry.aggregator_id);
20171
+ const lastMod = entry.last_modified_revision ?? 0;
20172
+ if (lastMod > this.currentRevision) {
20173
+ this.currentRevision = lastMod;
20174
+ }
19866
20175
  } catch {
19867
20176
  }
19868
20177
  }
@@ -20172,6 +20481,1648 @@ function stripKeyPrefix(key) {
20172
20481
  return key.slice(AGGREGATOR_PAYLOAD_KEY_PREFIX.length);
20173
20482
  }
20174
20483
 
20484
+ // src/sentinel/sentinel-finding-store.ts
20485
+ init_encryption();
20486
+ init_encoding();
20487
+
20488
+ // src/sentinel/types.ts
20489
+ var SENTINEL_SUMMARY_MAX_CHARS = 240;
20490
+ var SENTINEL_AUDIT_OPS = {
20491
+ SUBSCRIBED: "sentinel_subscribed",
20492
+ UNSUBSCRIBED: "sentinel_unsubscribed",
20493
+ FINDING_EMITTED: "sentinel_finding_emitted",
20494
+ EVALUATION_FAILED: "sentinel_evaluation_failed"
20495
+ };
20496
+ var SENTINEL_OBSERVED_AUDIT_OPS = {
20497
+ /** Proxy router emits this on every outbound call (success or failure). */
20498
+ PROXY_CALL_PREFIX: "proxy_call:"
20499
+ };
20500
+ function isProxyCallAuditEntry(entry) {
20501
+ return entry.operation.startsWith(
20502
+ SENTINEL_OBSERVED_AUDIT_OPS.PROXY_CALL_PREFIX
20503
+ );
20504
+ }
20505
+ function proxyServerFromAuditEntry(entry) {
20506
+ if (!isProxyCallAuditEntry(entry)) return null;
20507
+ const details = entry.details;
20508
+ if (!details) return null;
20509
+ const server = details["server"];
20510
+ if (typeof server !== "string" || server.length === 0) return null;
20511
+ return server;
20512
+ }
20513
+
20514
+ // src/sentinel/sentinel-finding-store.ts
20515
+ var SENTINEL_FINDING_NAMESPACE = "_sentinel_findings";
20516
+ var SENTINEL_FINDING_KEY_PREFIX = "finding.";
20517
+ var HKDF_INFO2 = "l2-sentinel-finding-v1";
20518
+ var DEFAULT_SENTINEL_FINDING_RETENTION_DAYS = 30;
20519
+ var MAX_FINDING_BYTES = 256 * 1024;
20520
+ var SentinelFindingStore = class {
20521
+ storage;
20522
+ encryptionKey;
20523
+ fortressId;
20524
+ retentionDays;
20525
+ now;
20526
+ constructor(opts) {
20527
+ this.storage = opts.storage;
20528
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
20529
+ this.fortressId = opts.fortressId;
20530
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_SENTINEL_FINDING_RETENTION_DAYS;
20531
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
20532
+ }
20533
+ /**
20534
+ * Persist a finding. Truncates the operator-visible summary to
20535
+ * SENTINEL_SUMMARY_MAX_CHARS so the dashboard render stays bounded.
20536
+ * Returns the retention deadline so callers can audit it.
20537
+ */
20538
+ async saveFinding(finding) {
20539
+ const truncated = {
20540
+ ...finding,
20541
+ fortress_id: this.fortressId,
20542
+ summary: truncateSummary(finding.summary)
20543
+ };
20544
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
20545
+ const retentionUntil = new Date(this.now().getTime() + retentionMs);
20546
+ const persisted = {
20547
+ version: 1,
20548
+ finding: truncated,
20549
+ retention_until: retentionUntil.toISOString()
20550
+ };
20551
+ const aad = stringToBytes(finding.finding_id);
20552
+ const plaintext = stringToBytes(JSON.stringify(persisted));
20553
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
20554
+ await this.storage.write(
20555
+ SENTINEL_FINDING_NAMESPACE,
20556
+ findingKey(finding.finding_id),
20557
+ stringToBytes(JSON.stringify(envelope))
20558
+ );
20559
+ return persisted.retention_until;
20560
+ }
20561
+ /** Load a single finding by id, or null when absent / corrupted. */
20562
+ async loadFinding(findingId) {
20563
+ let raw;
20564
+ try {
20565
+ raw = await this.storage.read(
20566
+ SENTINEL_FINDING_NAMESPACE,
20567
+ findingKey(findingId)
20568
+ );
20569
+ } catch {
20570
+ return null;
20571
+ }
20572
+ if (!raw) return null;
20573
+ if (raw.length > MAX_FINDING_BYTES) return null;
20574
+ return this.decode(findingId, raw);
20575
+ }
20576
+ /**
20577
+ * List findings, newest first. Optional filters: since (ISO 8601),
20578
+ * severity, sentinel_id, agent_id, limit. Default limit 100.
20579
+ */
20580
+ async listFindings(opts) {
20581
+ const metas = await this.storage.list(
20582
+ SENTINEL_FINDING_NAMESPACE,
20583
+ SENTINEL_FINDING_KEY_PREFIX
20584
+ );
20585
+ const findings = [];
20586
+ for (const meta of metas) {
20587
+ const id = stripKeyPrefix2(meta.key);
20588
+ if (id === null) continue;
20589
+ const raw = await this.storage.read(
20590
+ SENTINEL_FINDING_NAMESPACE,
20591
+ meta.key
20592
+ );
20593
+ if (!raw) continue;
20594
+ if (raw.length > MAX_FINDING_BYTES) continue;
20595
+ const finding = await this.decode(id, raw);
20596
+ if (!finding) continue;
20597
+ if (opts?.since && finding.observed_at < opts.since) continue;
20598
+ if (opts?.severity && finding.severity !== opts.severity) continue;
20599
+ if (opts?.sentinelId && finding.sentinel_id !== opts.sentinelId) continue;
20600
+ if (opts?.agentId && finding.agent_id !== opts.agentId) continue;
20601
+ findings.push(finding);
20602
+ }
20603
+ findings.sort((a, b) => a.observed_at < b.observed_at ? 1 : -1);
20604
+ const limit = opts?.limit ?? 100;
20605
+ return findings.slice(0, limit);
20606
+ }
20607
+ /**
20608
+ * Drop expired findings. Returns the count removed.
20609
+ */
20610
+ async pruneExpired(now) {
20611
+ const cutoff = (now ?? this.now()).toISOString();
20612
+ const metas = await this.storage.list(
20613
+ SENTINEL_FINDING_NAMESPACE,
20614
+ SENTINEL_FINDING_KEY_PREFIX
20615
+ );
20616
+ let pruned = 0;
20617
+ for (const meta of metas) {
20618
+ const id = stripKeyPrefix2(meta.key);
20619
+ if (id === null) continue;
20620
+ const raw = await this.storage.read(
20621
+ SENTINEL_FINDING_NAMESPACE,
20622
+ meta.key
20623
+ );
20624
+ if (!raw) continue;
20625
+ try {
20626
+ const aad = stringToBytes(id);
20627
+ const envelope = JSON.parse(bytesToString(raw));
20628
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
20629
+ const persisted = JSON.parse(
20630
+ bytesToString(plaintext)
20631
+ );
20632
+ if (persisted.retention_until <= cutoff) {
20633
+ await this.storage.delete(SENTINEL_FINDING_NAMESPACE, meta.key);
20634
+ pruned += 1;
20635
+ }
20636
+ } catch {
20637
+ }
20638
+ }
20639
+ return { pruned };
20640
+ }
20641
+ async decode(findingId, raw) {
20642
+ try {
20643
+ const aad = stringToBytes(findingId);
20644
+ const envelope = JSON.parse(bytesToString(raw));
20645
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
20646
+ const persisted = JSON.parse(
20647
+ bytesToString(plaintext)
20648
+ );
20649
+ if (persisted.version !== 1) return null;
20650
+ if (persisted.finding.finding_id !== findingId) return null;
20651
+ if (persisted.finding.fortress_id !== this.fortressId) return null;
20652
+ return persisted.finding;
20653
+ } catch {
20654
+ return null;
20655
+ }
20656
+ }
20657
+ };
20658
+ function findingKey(findingId) {
20659
+ return `${SENTINEL_FINDING_KEY_PREFIX}${findingId}`;
20660
+ }
20661
+ function stripKeyPrefix2(key) {
20662
+ if (!key.startsWith(SENTINEL_FINDING_KEY_PREFIX)) return null;
20663
+ return key.slice(SENTINEL_FINDING_KEY_PREFIX.length);
20664
+ }
20665
+ function truncateSummary(summary) {
20666
+ if (summary.length <= SENTINEL_SUMMARY_MAX_CHARS) return summary;
20667
+ return `${summary.slice(0, SENTINEL_SUMMARY_MAX_CHARS - 3)}...`;
20668
+ }
20669
+
20670
+ // src/sentinel/sentinel-registry.ts
20671
+ var SentinelRegistry = class {
20672
+ catalog = /* @__PURE__ */ new Map();
20673
+ subscribed = /* @__PURE__ */ new Map();
20674
+ register(entry) {
20675
+ if (this.catalog.has(entry.sentinelId)) {
20676
+ throw new Error(
20677
+ `sentinel-registry: ${entry.sentinelId} already registered`
20678
+ );
20679
+ }
20680
+ this.catalog.set(entry.sentinelId, entry);
20681
+ }
20682
+ /**
20683
+ * Available sentinels (catalog view). Operator UI lists this so the
20684
+ * operator can pick what to subscribe to.
20685
+ */
20686
+ listCatalog() {
20687
+ return [...this.catalog.values()].map((entry) => ({
20688
+ sentinelId: entry.sentinelId,
20689
+ description: entry.description
20690
+ }));
20691
+ }
20692
+ /** Currently subscribed sentinel ids. */
20693
+ listSubscribed() {
20694
+ return [...this.subscribed.keys()];
20695
+ }
20696
+ /** Has the fortress opted into this sentinel? */
20697
+ isSubscribed(sentinelId) {
20698
+ return this.subscribed.has(sentinelId);
20699
+ }
20700
+ /**
20701
+ * Subscribe a sentinel to a fortress context. Idempotent: a second
20702
+ * subscribe call on an already-subscribed sentinel returns the
20703
+ * existing instance without re-running `subscribe()`.
20704
+ */
20705
+ async subscribe(sentinelId, context) {
20706
+ const existing = this.subscribed.get(sentinelId);
20707
+ if (existing) return existing;
20708
+ const entry = this.catalog.get(sentinelId);
20709
+ if (!entry) {
20710
+ throw new Error(`sentinel-registry: unknown sentinel ${sentinelId}`);
20711
+ }
20712
+ const instance = entry.factory();
20713
+ await instance.subscribe(context);
20714
+ this.subscribed.set(sentinelId, instance);
20715
+ return instance;
20716
+ }
20717
+ /**
20718
+ * Unsubscribe. Idempotent: unsubscribing an unsubscribed sentinel
20719
+ * returns false without throwing. Returns true when an active
20720
+ * subscription was torn down.
20721
+ */
20722
+ async unsubscribe(sentinelId) {
20723
+ const instance = this.subscribed.get(sentinelId);
20724
+ if (!instance) return false;
20725
+ try {
20726
+ await instance.unsubscribe();
20727
+ } finally {
20728
+ this.subscribed.delete(sentinelId);
20729
+ }
20730
+ return true;
20731
+ }
20732
+ /**
20733
+ * Snapshot of subscribed sentinels for the dispatcher's tick path.
20734
+ * Returned as an array so the dispatcher can iterate without holding
20735
+ * the map under modification.
20736
+ */
20737
+ snapshotSubscribed() {
20738
+ return [...this.subscribed.entries()].map(([sentinelId, sentinel]) => ({
20739
+ sentinelId,
20740
+ sentinel
20741
+ }));
20742
+ }
20743
+ /**
20744
+ * Tear down every subscription. Called by the dispatcher on
20745
+ * fortress-shutdown. Best-effort: a failing unsubscribe does not
20746
+ * abort the rest.
20747
+ */
20748
+ async unsubscribeAll() {
20749
+ const ids = [...this.subscribed.keys()];
20750
+ for (const id of ids) {
20751
+ try {
20752
+ await this.unsubscribe(id);
20753
+ } catch {
20754
+ }
20755
+ }
20756
+ }
20757
+ };
20758
+ var DEFAULT_TICK_INTERVAL_MS = 6e4;
20759
+ var SentinelDispatcher = class {
20760
+ registry;
20761
+ findingStore;
20762
+ auditLog;
20763
+ fortressId;
20764
+ identityId;
20765
+ now;
20766
+ tickIntervalMs;
20767
+ listeners = /* @__PURE__ */ new Set();
20768
+ tickTimer = null;
20769
+ tickInFlight = false;
20770
+ constructor(deps) {
20771
+ this.registry = deps.registry;
20772
+ this.findingStore = deps.findingStore;
20773
+ this.auditLog = deps.auditLog;
20774
+ this.fortressId = deps.fortressId;
20775
+ this.identityId = deps.identityId;
20776
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
20777
+ this.tickIntervalMs = deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
20778
+ }
20779
+ /** Read-only view of the registry. Convenience for route handlers. */
20780
+ getRegistry() {
20781
+ return this.registry;
20782
+ }
20783
+ /** Read-only view of the finding store. Convenience for route handlers. */
20784
+ getFindingStore() {
20785
+ return this.findingStore;
20786
+ }
20787
+ /**
20788
+ * Subscribe an in-process listener. Returns an unsubscribe fn.
20789
+ */
20790
+ onEvent(listener) {
20791
+ this.listeners.add(listener);
20792
+ return () => this.listeners.delete(listener);
20793
+ }
20794
+ /**
20795
+ * Subscribe a sentinel to this fortress + emit the
20796
+ * `sentinel_subscribed` audit event. Wraps `registry.subscribe()` so
20797
+ * the audit emission lives at the dispatcher boundary (the
20798
+ * fortress-aware site).
20799
+ */
20800
+ async subscribeSentinel(sentinelId, contextOverrides) {
20801
+ const context = {
20802
+ fortressId: this.fortressId,
20803
+ auditLog: this.auditLog,
20804
+ now: this.now,
20805
+ ...contextOverrides ?? {}
20806
+ };
20807
+ const sentinel = await this.registry.subscribe(sentinelId, context);
20808
+ this.auditLog.append(
20809
+ "l2",
20810
+ SENTINEL_AUDIT_OPS.SUBSCRIBED,
20811
+ this.identityId,
20812
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
20813
+ );
20814
+ return sentinel;
20815
+ }
20816
+ /**
20817
+ * Unsubscribe + emit `sentinel_unsubscribed`. Returns true when an
20818
+ * active subscription was torn down. Audit fires only on successful
20819
+ * removal.
20820
+ */
20821
+ async unsubscribeSentinel(sentinelId) {
20822
+ const removed = await this.registry.unsubscribe(sentinelId);
20823
+ if (removed) {
20824
+ this.auditLog.append(
20825
+ "l2",
20826
+ SENTINEL_AUDIT_OPS.UNSUBSCRIBED,
20827
+ this.identityId,
20828
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
20829
+ );
20830
+ }
20831
+ return removed;
20832
+ }
20833
+ /**
20834
+ * Run one evaluation pass over every subscribed sentinel. Used by
20835
+ * the auto-tick AND by tests that want a synchronous evaluation
20836
+ * gate. Returns the findings produced this tick (already persisted
20837
+ * + audit-logged + emitted).
20838
+ */
20839
+ async tick() {
20840
+ if (this.tickInFlight) return [];
20841
+ this.tickInFlight = true;
20842
+ try {
20843
+ const subscribed = this.registry.snapshotSubscribed();
20844
+ const findings = [];
20845
+ for (const { sentinelId, sentinel } of subscribed) {
20846
+ try {
20847
+ const tickFindings = await sentinel.evaluate();
20848
+ for (const finding of tickFindings) {
20849
+ const stamped = await this.routeFinding(sentinelId, finding);
20850
+ findings.push(stamped);
20851
+ }
20852
+ } catch (err) {
20853
+ const errorMessage = err instanceof Error ? err.message : String(err);
20854
+ const observedAt = this.now().toISOString();
20855
+ this.auditLog.append(
20856
+ "l2",
20857
+ SENTINEL_AUDIT_OPS.EVALUATION_FAILED,
20858
+ this.identityId,
20859
+ {
20860
+ sentinel_id: sentinelId,
20861
+ fortress_id: this.fortressId,
20862
+ error_message: errorMessage
20863
+ },
20864
+ "failure"
20865
+ );
20866
+ this.emit({
20867
+ type: "evaluation_failed",
20868
+ sentinel_id: sentinelId,
20869
+ error_message: errorMessage,
20870
+ observed_at: observedAt
20871
+ });
20872
+ }
20873
+ }
20874
+ return findings;
20875
+ } finally {
20876
+ this.tickInFlight = false;
20877
+ }
20878
+ }
20879
+ /**
20880
+ * Start the auto-tick loop. No-op when tickIntervalMs is 0 or when
20881
+ * already started. Tests typically leave auto-tick off and call
20882
+ * `tick()` directly.
20883
+ */
20884
+ start() {
20885
+ if (this.tickTimer !== null) return;
20886
+ if (this.tickIntervalMs <= 0) return;
20887
+ this.tickTimer = setInterval(() => {
20888
+ void this.tick();
20889
+ }, this.tickIntervalMs);
20890
+ if (typeof this.tickTimer.unref === "function") {
20891
+ this.tickTimer.unref();
20892
+ }
20893
+ }
20894
+ /** Stop the auto-tick loop. Idempotent. */
20895
+ stop() {
20896
+ if (this.tickTimer === null) return;
20897
+ clearInterval(this.tickTimer);
20898
+ this.tickTimer = null;
20899
+ }
20900
+ /**
20901
+ * Tear down every subscription + stop the tick loop. Called on
20902
+ * fortress shutdown.
20903
+ */
20904
+ async dispose() {
20905
+ this.stop();
20906
+ await this.registry.unsubscribeAll();
20907
+ this.listeners.clear();
20908
+ }
20909
+ async routeFinding(sentinelId, raw) {
20910
+ const stamped = {
20911
+ ...raw,
20912
+ finding_id: raw.finding_id || randomUUID(),
20913
+ sentinel_id: sentinelId,
20914
+ fortress_id: this.fortressId,
20915
+ observed_at: raw.observed_at || this.now().toISOString()
20916
+ };
20917
+ await this.findingStore.saveFinding(stamped);
20918
+ this.auditLog.append(
20919
+ "l2",
20920
+ SENTINEL_AUDIT_OPS.FINDING_EMITTED,
20921
+ this.identityId,
20922
+ {
20923
+ sentinel_id: sentinelId,
20924
+ finding_id: stamped.finding_id,
20925
+ severity: stamped.severity,
20926
+ ...stamped.agent_id !== void 0 ? { agent_id: stamped.agent_id } : {},
20927
+ evidence_audit_ids: stamped.evidence_audit_ids,
20928
+ fortress_id: this.fortressId
20929
+ }
20930
+ );
20931
+ this.emit({ type: "finding", finding: stamped });
20932
+ return stamped;
20933
+ }
20934
+ emit(event) {
20935
+ for (const listener of this.listeners) {
20936
+ try {
20937
+ listener(event);
20938
+ } catch {
20939
+ }
20940
+ }
20941
+ }
20942
+ };
20943
+
20944
+ // src/sentinel/sentinel.ts
20945
+ var Sentinel = class {
20946
+ /**
20947
+ * Bind the sentinel to a fortress context. Called once on
20948
+ * subscribe. Default implementation stores the context on `this`;
20949
+ * sentinels that need additional setup (e.g. priming a baseline
20950
+ * cache) override.
20951
+ */
20952
+ async subscribe(context) {
20953
+ this.context = context;
20954
+ }
20955
+ /**
20956
+ * Tear down. Default implementation clears the context; subclasses
20957
+ * that hold timers or external handles override.
20958
+ */
20959
+ async unsubscribe() {
20960
+ this.context = void 0;
20961
+ }
20962
+ context;
20963
+ /** Internal helper: assert subscribed before evaluation. */
20964
+ requireContext() {
20965
+ if (!this.context) {
20966
+ throw new Error(
20967
+ `sentinel ${this.sentinelId}: evaluate() called before subscribe()`
20968
+ );
20969
+ }
20970
+ return this.context;
20971
+ }
20972
+ };
20973
+
20974
+ // src/sentinel/sentinels/egress-volume-watcher.ts
20975
+ var EGRESS_VOLUME_SENTINEL_ID = "egress-volume";
20976
+ var WARN_SIGMA = 3;
20977
+ var ALERT_SIGMA = 6;
20978
+ var BASELINE_WINDOWS = 7;
20979
+ var QUERY_LIMIT = 1e4;
20980
+ var EgressVolumeWatcher = class extends Sentinel {
20981
+ sentinelId = EGRESS_VOLUME_SENTINEL_ID;
20982
+ description = "Watches outbound proxy-call volume per upstream server. Emits warn/alert when current 24h volume exceeds the rolling 7-day baseline by 3x or 6x standard deviations.";
20983
+ /** Servers we have already produced an `info` baseline-established finding for. */
20984
+ baselineEstablished = /* @__PURE__ */ new Set();
20985
+ async evaluate() {
20986
+ const ctx = this.requireContext();
20987
+ const now = ctx.now();
20988
+ const windowMs = 24 * 60 * 60 * 1e3;
20989
+ const windowSpanMs = (BASELINE_WINDOWS + 1) * windowMs;
20990
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
20991
+ const queryResult = await ctx.auditLog.query({
20992
+ since: sinceIso,
20993
+ layer: "l2",
20994
+ limit: QUERY_LIMIT
20995
+ });
20996
+ const entries = queryResult.entries.filter(isProxyCallAuditEntry);
20997
+ const byServer = /* @__PURE__ */ new Map();
20998
+ for (const entry of entries) {
20999
+ const server = proxyServerFromAuditEntry(entry);
21000
+ if (server === null) continue;
21001
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
21002
+ if (auditAge < 0) continue;
21003
+ const windowIdx = Math.floor(auditAge / windowMs);
21004
+ if (windowIdx > BASELINE_WINDOWS) continue;
21005
+ let snapshot = byServer.get(server);
21006
+ if (!snapshot) {
21007
+ snapshot = { windows: [] };
21008
+ for (let i = 0; i <= BASELINE_WINDOWS; i += 1) {
21009
+ snapshot.windows.push({ count: 0, evidence_audit_ids: [] });
21010
+ }
21011
+ byServer.set(server, snapshot);
21012
+ }
21013
+ const bucket = snapshot.windows[windowIdx];
21014
+ bucket.count += 1;
21015
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
21016
+ bucket.evidence_audit_ids.push(`${entry.timestamp}:${entry.operation}`);
21017
+ }
21018
+ }
21019
+ const findings = [];
21020
+ for (const [server, snapshot] of byServer.entries()) {
21021
+ const finding = this.evaluateServer(server, snapshot, now);
21022
+ if (finding) findings.push(finding);
21023
+ }
21024
+ return findings;
21025
+ }
21026
+ /** Reset baseline-established memoization. Tests use this between runs. */
21027
+ resetBaselineMemo() {
21028
+ this.baselineEstablished.clear();
21029
+ }
21030
+ evaluateServer(server, snapshot, now) {
21031
+ const currentWindow = snapshot.windows[0];
21032
+ const baselineWindows = snapshot.windows.slice(1);
21033
+ const populatedBaselineWindows = baselineWindows.filter((w) => w.count > 0).length;
21034
+ if (populatedBaselineWindows < BASELINE_WINDOWS) {
21035
+ if (this.baselineEstablished.has(server)) return null;
21036
+ if (populatedBaselineWindows === 0 && currentWindow.count === 0) {
21037
+ return null;
21038
+ }
21039
+ return null;
21040
+ }
21041
+ const baselineCounts = baselineWindows.map((w) => w.count);
21042
+ const mean = baselineCounts.reduce((sum, c) => sum + c, 0) / baselineCounts.length;
21043
+ const variance = baselineCounts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / baselineCounts.length;
21044
+ const stddev = Math.sqrt(variance);
21045
+ const wasEstablished = this.baselineEstablished.has(server);
21046
+ this.baselineEstablished.add(server);
21047
+ if (!wasEstablished) {
21048
+ return {
21049
+ finding_id: "",
21050
+ sentinel_id: this.sentinelId,
21051
+ severity: "info",
21052
+ summary: `egress-volume baseline established for ${server}: mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS} prior days).`,
21053
+ details: {
21054
+ server,
21055
+ baseline_mean: mean,
21056
+ baseline_stddev: stddev,
21057
+ baseline_windows: baselineCounts,
21058
+ current_count: currentWindow.count
21059
+ },
21060
+ observed_at: now.toISOString(),
21061
+ evidence_audit_ids: [],
21062
+ fortress_id: ""
21063
+ };
21064
+ }
21065
+ const warnThreshold = mean + WARN_SIGMA * stddev;
21066
+ const alertThreshold = mean + ALERT_SIGMA * stddev;
21067
+ if (currentWindow.count > alertThreshold) {
21068
+ return this.buildAnomalyFinding(
21069
+ server,
21070
+ snapshot,
21071
+ mean,
21072
+ stddev,
21073
+ now,
21074
+ "alert",
21075
+ ALERT_SIGMA
21076
+ );
21077
+ }
21078
+ if (currentWindow.count > warnThreshold) {
21079
+ return this.buildAnomalyFinding(
21080
+ server,
21081
+ snapshot,
21082
+ mean,
21083
+ stddev,
21084
+ now,
21085
+ "warn",
21086
+ WARN_SIGMA
21087
+ );
21088
+ }
21089
+ return null;
21090
+ }
21091
+ buildAnomalyFinding(server, snapshot, mean, stddev, now, severity, sigma) {
21092
+ const currentWindow = snapshot.windows[0];
21093
+ const ratio = mean === 0 ? Infinity : currentWindow.count / mean;
21094
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21095
+ const summary = `${server} egress is ${ratioStr}: ${currentWindow.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
21096
+ return {
21097
+ finding_id: "",
21098
+ sentinel_id: this.sentinelId,
21099
+ severity,
21100
+ summary,
21101
+ details: {
21102
+ server,
21103
+ current_count: currentWindow.count,
21104
+ baseline_mean: mean,
21105
+ baseline_stddev: stddev,
21106
+ sigma_threshold: sigma,
21107
+ ratio
21108
+ },
21109
+ observed_at: now.toISOString(),
21110
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
21111
+ fortress_id: ""
21112
+ };
21113
+ }
21114
+ };
21115
+
21116
+ // src/sentinel/sentinels/cross-agent-chatter-watcher.ts
21117
+ var CROSS_AGENT_CHATTER_SENTINEL_ID = "cross-agent-chatter";
21118
+ var WARN_SIGMA2 = 3;
21119
+ var ALERT_SIGMA2 = 6;
21120
+ var BASELINE_WINDOWS2 = 7;
21121
+ var QUERY_LIMIT2 = 1e4;
21122
+ var MULTI_NEW_PARTNER_ALERT_THRESHOLD = 3;
21123
+ var OPERATOR_PSEUDO_AGENT = "operator";
21124
+ var HANDOFF_OP = "v1.1_local_handoff";
21125
+ var CROSS_HARNESS_OPS = /* @__PURE__ */ new Set([
21126
+ "cross_harness_approval_aggregated",
21127
+ "cross_harness_approval_resolved"
21128
+ ]);
21129
+ function pairKey(sender, recipient) {
21130
+ return `${sender}|${recipient}`;
21131
+ }
21132
+ function pairFromKey(key) {
21133
+ const idx = key.indexOf("|");
21134
+ return { sender: key.slice(0, idx), recipient: key.slice(idx + 1) };
21135
+ }
21136
+ var CrossAgentChatterWatcher = class extends Sentinel {
21137
+ sentinelId = CROSS_AGENT_CHATTER_SENTINEL_ID;
21138
+ description = "Watches inter-agent communication patterns. Surfaces per-pair rate spikes (3 or 6 sigma over the rolling 7-day baseline) and new-partner appearances. Escalates to alert when one source agent picks up 3 or more new partners in 24h (lateral-movement shape).";
21139
+ /** Pair keys we have already produced a baseline-established info finding for. */
21140
+ baselineEstablished = /* @__PURE__ */ new Set();
21141
+ async evaluate() {
21142
+ const ctx = this.requireContext();
21143
+ const now = ctx.now();
21144
+ const windowMs = 24 * 60 * 60 * 1e3;
21145
+ const windowSpanMs = (BASELINE_WINDOWS2 + 1) * windowMs;
21146
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21147
+ const queryResult = await ctx.auditLog.query({
21148
+ since: sinceIso,
21149
+ layer: "l2",
21150
+ limit: QUERY_LIMIT2
21151
+ });
21152
+ const events = extractInterAgentEvents(queryResult.entries);
21153
+ const byPair = /* @__PURE__ */ new Map();
21154
+ for (const event of events) {
21155
+ const auditAgeMs = now.getTime() - event.timestampMs;
21156
+ if (auditAgeMs < 0) continue;
21157
+ const windowIdx = Math.floor(auditAgeMs / windowMs);
21158
+ if (windowIdx > BASELINE_WINDOWS2) continue;
21159
+ const key = pairKey(event.sender, event.recipient);
21160
+ let snap = byPair.get(key);
21161
+ if (!snap) {
21162
+ snap = { windows: [] };
21163
+ for (let i = 0; i <= BASELINE_WINDOWS2; i += 1) {
21164
+ snap.windows.push({ count: 0, evidence_audit_ids: [] });
21165
+ }
21166
+ byPair.set(key, snap);
21167
+ }
21168
+ const bucket = snap.windows[windowIdx];
21169
+ bucket.count += 1;
21170
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
21171
+ bucket.evidence_audit_ids.push(event.auditId);
21172
+ }
21173
+ }
21174
+ const findings = [];
21175
+ for (const [key, snap] of byPair.entries()) {
21176
+ const finding = this.evaluatePair(key, snap, now);
21177
+ if (finding) findings.push(finding);
21178
+ }
21179
+ const newPartnersBySource = computeNewPartners(byPair);
21180
+ for (const [source, partners] of newPartnersBySource.entries()) {
21181
+ const finding = this.buildNewPartnerFinding(source, partners, now);
21182
+ if (finding) findings.push(finding);
21183
+ }
21184
+ return findings;
21185
+ }
21186
+ /** Reset baseline-established memoization. Tests use this between runs. */
21187
+ resetBaselineMemo() {
21188
+ this.baselineEstablished.clear();
21189
+ }
21190
+ evaluatePair(key, snap, now) {
21191
+ const currentWindow = snap.windows[0];
21192
+ const baselineWindows = snap.windows.slice(1);
21193
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
21194
+ if (populated < BASELINE_WINDOWS2) {
21195
+ return null;
21196
+ }
21197
+ const counts = baselineWindows.map((w) => w.count);
21198
+ const mean = counts.reduce((s, c) => s + c, 0) / counts.length;
21199
+ const variance = counts.reduce((s, c) => s + (c - mean) ** 2, 0) / counts.length;
21200
+ const stddev = Math.sqrt(variance);
21201
+ const wasEstablished = this.baselineEstablished.has(key);
21202
+ this.baselineEstablished.add(key);
21203
+ if (!wasEstablished) {
21204
+ const pair = pairFromKey(key);
21205
+ return {
21206
+ finding_id: "",
21207
+ sentinel_id: this.sentinelId,
21208
+ severity: "info",
21209
+ summary: `cross-agent-chatter baseline established for ${pair.sender} -> ${pair.recipient}: mean ${mean.toFixed(1)} msgs/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS2} prior days).`,
21210
+ details: {
21211
+ sender_agent_id: pair.sender,
21212
+ recipient_agent_id: pair.recipient,
21213
+ baseline_mean: mean,
21214
+ baseline_stddev: stddev,
21215
+ baseline_windows: counts,
21216
+ current_count: currentWindow.count
21217
+ },
21218
+ observed_at: now.toISOString(),
21219
+ evidence_audit_ids: [],
21220
+ fortress_id: ""
21221
+ };
21222
+ }
21223
+ const warnThreshold = mean + WARN_SIGMA2 * stddev;
21224
+ const alertThreshold = mean + ALERT_SIGMA2 * stddev;
21225
+ if (currentWindow.count > alertThreshold) {
21226
+ return this.buildRateSpike(key, snap, mean, stddev, now, "alert", ALERT_SIGMA2);
21227
+ }
21228
+ if (currentWindow.count > warnThreshold) {
21229
+ return this.buildRateSpike(key, snap, mean, stddev, now, "warn", WARN_SIGMA2);
21230
+ }
21231
+ return null;
21232
+ }
21233
+ buildRateSpike(key, snap, mean, stddev, now, severity, sigma) {
21234
+ const pair = pairFromKey(key);
21235
+ const cur = snap.windows[0];
21236
+ const ratio = mean === 0 ? Infinity : cur.count / mean;
21237
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21238
+ const summary = `${pair.sender} -> ${pair.recipient} chatter rate is ${ratioStr}: ${cur.count} cross-agent messages in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
21239
+ return {
21240
+ finding_id: "",
21241
+ sentinel_id: this.sentinelId,
21242
+ severity,
21243
+ summary,
21244
+ details: {
21245
+ sender_agent_id: pair.sender,
21246
+ recipient_agent_id: pair.recipient,
21247
+ current_count: cur.count,
21248
+ baseline_mean: mean,
21249
+ baseline_stddev: stddev,
21250
+ sigma_threshold: sigma,
21251
+ ratio
21252
+ },
21253
+ observed_at: now.toISOString(),
21254
+ agent_id: pair.sender,
21255
+ evidence_audit_ids: cur.evidence_audit_ids,
21256
+ fortress_id: ""
21257
+ };
21258
+ }
21259
+ buildNewPartnerFinding(source, info, now) {
21260
+ if (info.partners.length === 0) return null;
21261
+ const severity = info.partners.length >= MULTI_NEW_PARTNER_ALERT_THRESHOLD ? "alert" : "warn";
21262
+ const partnerList = info.partners.join(", ");
21263
+ const baselinePartnerList = info.priorPartners.length === 0 ? "no prior partners" : info.priorPartners.join(", ");
21264
+ const summary = severity === "alert" ? `${source} began communicating with ${info.partners.length} new partners in 24h (${partnerList}). Prior partners: ${baselinePartnerList}. Multi-new-partner pattern crossed alert threshold (>=${MULTI_NEW_PARTNER_ALERT_THRESHOLD}).` : `${source} began communicating with a new partner today: ${partnerList}. Prior partners: ${baselinePartnerList}.`;
21265
+ return {
21266
+ finding_id: "",
21267
+ sentinel_id: this.sentinelId,
21268
+ severity,
21269
+ summary,
21270
+ details: {
21271
+ sender_agent_id: source,
21272
+ new_partners: info.partners,
21273
+ prior_partners: info.priorPartners,
21274
+ new_partner_count: info.partners.length,
21275
+ multi_new_partner_threshold: MULTI_NEW_PARTNER_ALERT_THRESHOLD
21276
+ },
21277
+ observed_at: now.toISOString(),
21278
+ agent_id: source,
21279
+ evidence_audit_ids: info.evidenceAuditIds,
21280
+ fortress_id: ""
21281
+ };
21282
+ }
21283
+ };
21284
+ function computeNewPartners(byPair) {
21285
+ const currentBySource = /* @__PURE__ */ new Map();
21286
+ const priorBySource = /* @__PURE__ */ new Map();
21287
+ for (const [key, snap] of byPair.entries()) {
21288
+ const { sender, recipient } = pairFromKey(key);
21289
+ if (snap.windows[0] && snap.windows[0].count > 0) {
21290
+ let recipMap = currentBySource.get(sender);
21291
+ if (!recipMap) {
21292
+ recipMap = /* @__PURE__ */ new Map();
21293
+ currentBySource.set(sender, recipMap);
21294
+ }
21295
+ recipMap.set(recipient, snap.windows[0].evidence_audit_ids);
21296
+ }
21297
+ const priorTouched = snap.windows.slice(1).some((w) => w.count > 0);
21298
+ if (priorTouched) {
21299
+ let set = priorBySource.get(sender);
21300
+ if (!set) {
21301
+ set = /* @__PURE__ */ new Set();
21302
+ priorBySource.set(sender, set);
21303
+ }
21304
+ set.add(recipient);
21305
+ }
21306
+ }
21307
+ const out = /* @__PURE__ */ new Map();
21308
+ for (const [sender, recipMap] of currentBySource.entries()) {
21309
+ const prior = priorBySource.get(sender) ?? /* @__PURE__ */ new Set();
21310
+ if (prior.size === 0) {
21311
+ continue;
21312
+ }
21313
+ const newPartners = [];
21314
+ const evidence = [];
21315
+ for (const [recipient, recipEvidence] of recipMap.entries()) {
21316
+ if (!prior.has(recipient)) {
21317
+ newPartners.push(recipient);
21318
+ for (const id of recipEvidence) {
21319
+ if (evidence.length < 50) evidence.push(id);
21320
+ }
21321
+ }
21322
+ }
21323
+ if (newPartners.length === 0) continue;
21324
+ newPartners.sort();
21325
+ out.set(sender, {
21326
+ partners: newPartners,
21327
+ priorPartners: [...prior].sort(),
21328
+ evidenceAuditIds: evidence
21329
+ });
21330
+ }
21331
+ return out;
21332
+ }
21333
+ function extractInterAgentEvents(entries) {
21334
+ const out = [];
21335
+ for (const entry of entries) {
21336
+ const op = entry.operation;
21337
+ if (op === HANDOFF_OP) {
21338
+ const details = entry.details;
21339
+ const sender = optionalString(details, "sender_agent_id");
21340
+ const recipient = optionalString(details, "recipient_agent_id");
21341
+ if (!sender || !recipient || sender === recipient) continue;
21342
+ out.push({
21343
+ sender,
21344
+ recipient,
21345
+ timestampMs: Date.parse(entry.timestamp),
21346
+ auditId: `${entry.timestamp}:${entry.operation}`
21347
+ });
21348
+ continue;
21349
+ }
21350
+ if (CROSS_HARNESS_OPS.has(op)) {
21351
+ const details = entry.details;
21352
+ const sender = optionalString(details, "source_harness") ?? optionalString(details, "source_agent_id");
21353
+ if (!sender) continue;
21354
+ out.push({
21355
+ sender,
21356
+ recipient: OPERATOR_PSEUDO_AGENT,
21357
+ timestampMs: Date.parse(entry.timestamp),
21358
+ auditId: `${entry.timestamp}:${entry.operation}`
21359
+ });
21360
+ }
21361
+ }
21362
+ return out;
21363
+ }
21364
+ function optionalString(details, key) {
21365
+ if (!details) return null;
21366
+ const value = details[key];
21367
+ if (typeof value !== "string" || value.length === 0) return null;
21368
+ return value;
21369
+ }
21370
+
21371
+ // src/sentinel/sentinels/credential-usage-watcher.ts
21372
+ var CREDENTIAL_USAGE_SENTINEL_ID = "credential-usage";
21373
+ var WARN_SIGMA3 = 3;
21374
+ var ALERT_SIGMA3 = 6;
21375
+ var NEW_PAIR_ALERT_COUNT = 3;
21376
+ var BASELINE_WINDOWS3 = 7;
21377
+ var QUERY_LIMIT3 = 2e4;
21378
+ var BROKER_SECRET_READ_OP = "broker_secret_read";
21379
+ var BROKER_TOKEN_ISSUED_OP = "broker_token_issued";
21380
+ var CredentialUsageWatcher = class extends Sentinel {
21381
+ sentinelId = CREDENTIAL_USAGE_SENTINEL_ID;
21382
+ description = "Watches per-agent credential reads. Emits warn/alert when a specific (agent, secret) usage rate exceeds the rolling 7-day baseline, or when an agent uses an unfamiliar combination of secrets together in one 24h window.";
21383
+ /**
21384
+ * Memoization of (agent, secret) pairs whose baseline has been
21385
+ * established. Same shape Phi-1 uses to avoid re-emitting `info`
21386
+ * findings on every tick after a baseline first establishes.
21387
+ *
21388
+ * Phi-2 deliberately does NOT emit `info` findings: per-pair
21389
+ * baselines on a busy fortress would be too noisy. The memo is
21390
+ * kept here for parity with Phi-1's reset hook so tests can clear
21391
+ * state between runs.
21392
+ */
21393
+ baselineEstablished = /* @__PURE__ */ new Set();
21394
+ /** Reset memoization. Tests use this between runs. */
21395
+ resetBaselineMemo() {
21396
+ this.baselineEstablished.clear();
21397
+ }
21398
+ async evaluate() {
21399
+ const ctx = this.requireContext();
21400
+ const now = ctx.now();
21401
+ const windowMs = 24 * 60 * 60 * 1e3;
21402
+ const windowSpanMs = (BASELINE_WINDOWS3 + 1) * windowMs;
21403
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21404
+ const queryResult = await ctx.auditLog.query({
21405
+ since: sinceIso,
21406
+ layer: "l3",
21407
+ limit: QUERY_LIMIT3
21408
+ });
21409
+ const entries = queryResult.entries.filter(isCredentialAuditEntry);
21410
+ const byPair = /* @__PURE__ */ new Map();
21411
+ const byAgent = /* @__PURE__ */ new Map();
21412
+ for (const entry of entries) {
21413
+ const agentId = extractAgentId(entry);
21414
+ const secretId = extractSecretId(entry);
21415
+ if (agentId === null || secretId === null) continue;
21416
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
21417
+ if (auditAge < 0) continue;
21418
+ const windowIdx = Math.floor(auditAge / windowMs);
21419
+ if (windowIdx > BASELINE_WINDOWS3) continue;
21420
+ const pairKey2 = `${agentId}\0${secretId}`;
21421
+ let pairSnapshot = byPair.get(pairKey2);
21422
+ if (!pairSnapshot) {
21423
+ pairSnapshot = {
21424
+ windows: Array.from({ length: BASELINE_WINDOWS3 + 1 }, () => ({
21425
+ count: 0,
21426
+ evidence_audit_ids: []
21427
+ }))
21428
+ };
21429
+ byPair.set(pairKey2, pairSnapshot);
21430
+ }
21431
+ const bucket = pairSnapshot.windows[windowIdx];
21432
+ bucket.count += 1;
21433
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
21434
+ bucket.evidence_audit_ids.push(
21435
+ `${entry.timestamp}:${entry.operation}`
21436
+ );
21437
+ }
21438
+ let agentState = byAgent.get(agentId);
21439
+ if (!agentState) {
21440
+ agentState = {
21441
+ currentSecrets: /* @__PURE__ */ new Set(),
21442
+ currentEvidence: [],
21443
+ baselineSecretsByWindow: Array.from(
21444
+ { length: BASELINE_WINDOWS3 },
21445
+ () => /* @__PURE__ */ new Set()
21446
+ ),
21447
+ baselinePopulatedWindows: 0
21448
+ };
21449
+ byAgent.set(agentId, agentState);
21450
+ }
21451
+ if (windowIdx === 0) {
21452
+ agentState.currentSecrets.add(secretId);
21453
+ if (agentState.currentEvidence.length < 50) {
21454
+ agentState.currentEvidence.push(
21455
+ `${entry.timestamp}:${entry.operation}`
21456
+ );
21457
+ }
21458
+ } else {
21459
+ const baselineIdx = windowIdx - 1;
21460
+ agentState.baselineSecretsByWindow[baselineIdx].add(secretId);
21461
+ }
21462
+ }
21463
+ const findings = [];
21464
+ for (const [pairKey2, snapshot] of byPair.entries()) {
21465
+ const [agentId, secretId] = pairKey2.split("\0");
21466
+ const finding = this.evaluateRateSpike(
21467
+ agentId,
21468
+ secretId,
21469
+ snapshot,
21470
+ now
21471
+ );
21472
+ if (finding) findings.push(finding);
21473
+ }
21474
+ for (const [agentId, agentState] of byAgent.entries()) {
21475
+ agentState.baselinePopulatedWindows = agentState.baselineSecretsByWindow.filter((s) => s.size > 0).length;
21476
+ const finding = this.evaluateNewPairs(agentId, agentState, now);
21477
+ if (finding) findings.push(finding);
21478
+ }
21479
+ return findings;
21480
+ }
21481
+ evaluateRateSpike(agentId, secretId, snapshot, now) {
21482
+ const currentWindow = snapshot.windows[0];
21483
+ const baselineWindows = snapshot.windows.slice(1);
21484
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
21485
+ if (populated < BASELINE_WINDOWS3) {
21486
+ return null;
21487
+ }
21488
+ const counts = baselineWindows.map((w) => w.count);
21489
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
21490
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
21491
+ const stddev = Math.sqrt(variance);
21492
+ const pairKey2 = `${agentId}\0${secretId}`;
21493
+ this.baselineEstablished.add(pairKey2);
21494
+ const warnThreshold = mean + WARN_SIGMA3 * stddev;
21495
+ const alertThreshold = mean + ALERT_SIGMA3 * stddev;
21496
+ if (currentWindow.count > alertThreshold) {
21497
+ return this.buildRateFinding(
21498
+ agentId,
21499
+ secretId,
21500
+ currentWindow,
21501
+ mean,
21502
+ stddev,
21503
+ now,
21504
+ "alert",
21505
+ ALERT_SIGMA3
21506
+ );
21507
+ }
21508
+ if (currentWindow.count > warnThreshold) {
21509
+ return this.buildRateFinding(
21510
+ agentId,
21511
+ secretId,
21512
+ currentWindow,
21513
+ mean,
21514
+ stddev,
21515
+ now,
21516
+ "warn",
21517
+ WARN_SIGMA3
21518
+ );
21519
+ }
21520
+ return null;
21521
+ }
21522
+ buildRateFinding(agentId, secretId, currentWindow, mean, stddev, now, severity, sigma) {
21523
+ const ratio = mean === 0 ? Number.POSITIVE_INFINITY : currentWindow.count / mean;
21524
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21525
+ const summary = `${agentId} agent used ${secretId} ${currentWindow.count} times in last 24h, baseline ${mean.toFixed(1)} +/- ${stddev.toFixed(1)} (${ratioStr}). Crossed +${sigma} sigma threshold.`;
21526
+ return {
21527
+ finding_id: "",
21528
+ sentinel_id: this.sentinelId,
21529
+ severity,
21530
+ agent_id: agentId,
21531
+ summary,
21532
+ details: {
21533
+ agent_id: agentId,
21534
+ secret_id: secretId,
21535
+ current_count: currentWindow.count,
21536
+ baseline_mean: mean,
21537
+ baseline_stddev: stddev,
21538
+ sigma_threshold: sigma,
21539
+ ratio: Number.isFinite(ratio) ? ratio : null
21540
+ },
21541
+ observed_at: now.toISOString(),
21542
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
21543
+ fortress_id: ""
21544
+ };
21545
+ }
21546
+ evaluateNewPairs(agentId, state, now) {
21547
+ if (state.baselinePopulatedWindows < BASELINE_WINDOWS3) {
21548
+ return null;
21549
+ }
21550
+ if (state.currentSecrets.size < 2) return null;
21551
+ const currentPairs = enumerateUnorderedPairs(state.currentSecrets);
21552
+ const historicalPairs = /* @__PURE__ */ new Set();
21553
+ for (const secretSet of state.baselineSecretsByWindow) {
21554
+ for (const pair of enumerateUnorderedPairs(secretSet)) {
21555
+ historicalPairs.add(pair);
21556
+ }
21557
+ }
21558
+ const newPairs = [];
21559
+ for (const pair of currentPairs) {
21560
+ if (historicalPairs.has(pair)) continue;
21561
+ const [a, b] = pair.split("\0");
21562
+ newPairs.push([a, b]);
21563
+ }
21564
+ if (newPairs.length === 0) return null;
21565
+ const severity = newPairs.length >= NEW_PAIR_ALERT_COUNT ? "alert" : "warn";
21566
+ const summary = buildNewPairSummary(agentId, newPairs);
21567
+ return {
21568
+ finding_id: "",
21569
+ sentinel_id: this.sentinelId,
21570
+ severity,
21571
+ agent_id: agentId,
21572
+ summary,
21573
+ details: {
21574
+ agent_id: agentId,
21575
+ new_pairs: newPairs,
21576
+ new_pair_count: newPairs.length,
21577
+ historical_pair_count: historicalPairs.size,
21578
+ current_pair_count: currentPairs.size
21579
+ },
21580
+ observed_at: now.toISOString(),
21581
+ evidence_audit_ids: state.currentEvidence,
21582
+ fortress_id: ""
21583
+ };
21584
+ }
21585
+ };
21586
+ function isCredentialAuditEntry(entry) {
21587
+ if (entry.result !== "success") return false;
21588
+ return entry.operation === BROKER_SECRET_READ_OP || entry.operation === BROKER_TOKEN_ISSUED_OP;
21589
+ }
21590
+ function extractAgentId(entry) {
21591
+ const details = entry.details;
21592
+ if (!details) return null;
21593
+ const agent = details["agent"];
21594
+ return typeof agent === "string" && agent.length > 0 ? agent : null;
21595
+ }
21596
+ function extractSecretId(entry) {
21597
+ const details = entry.details;
21598
+ if (!details) return null;
21599
+ const secret = details["secret"];
21600
+ return typeof secret === "string" && secret.length > 0 ? secret : null;
21601
+ }
21602
+ function enumerateUnorderedPairs(secrets) {
21603
+ const out = /* @__PURE__ */ new Set();
21604
+ const arr = [...secrets].sort();
21605
+ for (let i = 0; i < arr.length; i += 1) {
21606
+ for (let j = i + 1; j < arr.length; j += 1) {
21607
+ out.add(`${arr[i]}\0${arr[j]}`);
21608
+ }
21609
+ }
21610
+ return out;
21611
+ }
21612
+ function buildNewPairSummary(agentId, newPairs) {
21613
+ const first = newPairs[0];
21614
+ if (newPairs.length === 1) {
21615
+ return `${agentId} agent used ${first[0]} and ${first[1]} together for the first time today. This combination does not appear in historical sessions.`;
21616
+ }
21617
+ return `${agentId} agent introduced ${newPairs.length} unfamiliar credential pairs in the last 24h. First: ${first[0]} + ${first[1]}. This pattern is new for this agent.`;
21618
+ }
21619
+
21620
+ // src/sentinel/sentinels/suspicious-tool-call-detector.ts
21621
+ var SUSPICIOUS_TOOL_CALL_SENTINEL_ID = "suspicious-tool-call";
21622
+ var GATE_PREFIXES = [
21623
+ "gate_allow:",
21624
+ "gate_allow_proxy:",
21625
+ "gate_deny:",
21626
+ "gate_unclassified:"
21627
+ ];
21628
+ var WARN_SIGMA4 = 3;
21629
+ var ALERT_SIGMA4 = 6;
21630
+ var BASELINE_WINDOWS4 = 7;
21631
+ var ALERT_NOVEL_COMBINATIONS = 2;
21632
+ var TASK_WINDOW_MS = 60 * 60 * 1e3;
21633
+ var TRUNCATION_WARN_THRESHOLD = 5;
21634
+ var QUERY_LIMIT4 = 1e4;
21635
+ var SIGNATURE_PATTERNS = {
21636
+ /** >=5 percent-encoded sequences in a single visible value. */
21637
+ urlEncodedThreshold: 5,
21638
+ /** >=40 contiguous base64 chars in a single visible value. */
21639
+ base64MinRun: 40,
21640
+ /** Shell metacharacter set. */
21641
+ shellMetacharRegex: /(?:&&|\|\||;|\$\(|`|\|\s)/
21642
+ };
21643
+ var SuspiciousToolCallDetector = class extends Sentinel {
21644
+ sentinelId = SUSPICIOUS_TOOL_CALL_SENTINEL_ID;
21645
+ description = "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history. Rule-based heuristics with optional LLM-assist on ambiguous matches.";
21646
+ /** Servers we have already produced an `info` baseline-established finding for. */
21647
+ baselineEstablished = /* @__PURE__ */ new Set();
21648
+ /** Memoized known novel-combination keys (sorted-tools-csv). */
21649
+ knownCombinations = /* @__PURE__ */ new Set();
21650
+ /** Tasks observed where a novel combination already produced a finding. */
21651
+ novelCombinationsReported = /* @__PURE__ */ new Set();
21652
+ async evaluate() {
21653
+ const ctx = this.requireContext();
21654
+ const now = ctx.now();
21655
+ const dayMs = 24 * 60 * 60 * 1e3;
21656
+ const windowSpanMs = (BASELINE_WINDOWS4 + 1) * dayMs;
21657
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21658
+ const queryResult = await ctx.auditLog.query({
21659
+ since: sinceIso,
21660
+ layer: "l2",
21661
+ limit: QUERY_LIMIT4
21662
+ });
21663
+ const observations = [];
21664
+ for (const entry of queryResult.entries) {
21665
+ const obs = this.observationFromEntry(entry);
21666
+ if (obs && obs.ts <= now.getTime()) observations.push(obs);
21667
+ }
21668
+ if (observations.length === 0) return [];
21669
+ const findings = [];
21670
+ const layerAFindings = await this.runLayerA(observations, now, ctx);
21671
+ findings.push(...layerAFindings);
21672
+ const layerBFindings = this.runLayerB(observations, now);
21673
+ findings.push(...layerBFindings);
21674
+ const layerCFindings = this.runLayerC(observations, now);
21675
+ findings.push(...layerCFindings);
21676
+ return findings;
21677
+ }
21678
+ /** Reset memoization between test runs. Mirrors Phi-1's reset hook. */
21679
+ resetMemo() {
21680
+ this.baselineEstablished.clear();
21681
+ this.knownCombinations.clear();
21682
+ this.novelCombinationsReported.clear();
21683
+ }
21684
+ // ── Layer A ───────────────────────────────────────────────────────
21685
+ async runLayerA(observations, now, ctx) {
21686
+ const dayMs = 24 * 60 * 60 * 1e3;
21687
+ const recent = observations.filter(
21688
+ (o) => now.getTime() - o.ts <= dayMs
21689
+ );
21690
+ if (recent.length === 0) return [];
21691
+ const findings = [];
21692
+ const historical = observations.filter(
21693
+ (o) => now.getTime() - o.ts > dayMs
21694
+ );
21695
+ const perTool = /* @__PURE__ */ new Map();
21696
+ const ensureTool = (tool) => {
21697
+ let w = perTool.get(tool);
21698
+ if (!w) {
21699
+ w = {
21700
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
21701
+ count: 0,
21702
+ evidenceIds: []
21703
+ })),
21704
+ knownSignatures: /* @__PURE__ */ new Set()
21705
+ };
21706
+ perTool.set(tool, w);
21707
+ }
21708
+ return w;
21709
+ };
21710
+ for (const o of historical) {
21711
+ ensureTool(o.tool).knownSignatures.add(this.signatureOf(o.argsSummary));
21712
+ }
21713
+ const classify = this.classifyHandle(ctx);
21714
+ for (const obs of recent) {
21715
+ const matches = this.matchSignatures(obs);
21716
+ if (matches.length === 0) continue;
21717
+ const ambiguous = matches.every((m) => m === "base64_chunk");
21718
+ if (ambiguous && classify) {
21719
+ const verdict = await this.consultClassifier(classify, obs);
21720
+ if (verdict !== "suspicious") continue;
21721
+ }
21722
+ findings.push(
21723
+ this.buildLayerAFinding(obs, matches, now, classify ? "llm-assist" : "rule-based")
21724
+ );
21725
+ }
21726
+ for (const obs of recent) {
21727
+ const tool = obs.tool;
21728
+ const sig = this.signatureOf(obs.argsSummary);
21729
+ const known = perTool.get(tool)?.knownSignatures;
21730
+ if (known && known.size > 0 && !known.has(sig)) {
21731
+ findings.push(
21732
+ this.buildNovelSignatureFinding(obs, sig, now)
21733
+ );
21734
+ }
21735
+ }
21736
+ return findings;
21737
+ }
21738
+ matchSignatures(obs) {
21739
+ const out = [];
21740
+ const truncCount = countTruncatedValues(obs.argsSummary);
21741
+ if (truncCount >= TRUNCATION_WARN_THRESHOLD) out.push("truncation_burst");
21742
+ let urlBlob = false;
21743
+ let base64Blob = false;
21744
+ let shellChars = false;
21745
+ for (const value of Object.values(obs.argsSummary)) {
21746
+ if (typeof value !== "string") continue;
21747
+ if (countUrlEncoded(value) >= SIGNATURE_PATTERNS.urlEncodedThreshold) {
21748
+ urlBlob = true;
21749
+ }
21750
+ if (longestBase64Run(value) >= SIGNATURE_PATTERNS.base64MinRun) {
21751
+ base64Blob = true;
21752
+ }
21753
+ if (SIGNATURE_PATTERNS.shellMetacharRegex.test(value)) {
21754
+ shellChars = true;
21755
+ }
21756
+ }
21757
+ if (urlBlob) out.push("url_encoded_blob");
21758
+ if (base64Blob) out.push("base64_chunk");
21759
+ if (shellChars) out.push("shell_metachar");
21760
+ return out;
21761
+ }
21762
+ signatureOf(argsSummary) {
21763
+ return Object.keys(argsSummary).sort().join(",");
21764
+ }
21765
+ buildLayerAFinding(obs, matches, now, detectionPath) {
21766
+ const severity = matches.includes("shell_metachar") ? "alert" : "warn";
21767
+ const summary = `${obs.tool}: tool-call argument matches signature ${matches.join(", ")} (${detectionPath}).`;
21768
+ return {
21769
+ finding_id: "",
21770
+ sentinel_id: this.sentinelId,
21771
+ severity,
21772
+ summary: truncateSummary2(summary),
21773
+ details: {
21774
+ layer: "A",
21775
+ tool: obs.tool,
21776
+ proxy: obs.proxy,
21777
+ signatures: matches,
21778
+ detection_path: detectionPath
21779
+ },
21780
+ observed_at: now.toISOString(),
21781
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
21782
+ fortress_id: ""
21783
+ };
21784
+ }
21785
+ buildNovelSignatureFinding(obs, signature, now) {
21786
+ return {
21787
+ finding_id: "",
21788
+ sentinel_id: this.sentinelId,
21789
+ severity: "warn",
21790
+ summary: truncateSummary2(
21791
+ `${obs.tool}: novel argument-key signature observed (${signature || "<no-args>"}).`
21792
+ ),
21793
+ details: {
21794
+ layer: "A",
21795
+ tool: obs.tool,
21796
+ proxy: obs.proxy,
21797
+ signatures: ["novel_signature"],
21798
+ detection_path: "rule-based",
21799
+ novel_signature: signature
21800
+ },
21801
+ observed_at: now.toISOString(),
21802
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
21803
+ fortress_id: ""
21804
+ };
21805
+ }
21806
+ // ── Layer B ───────────────────────────────────────────────────────
21807
+ runLayerB(observations, now) {
21808
+ const dayMs = 24 * 60 * 60 * 1e3;
21809
+ const perTool = /* @__PURE__ */ new Map();
21810
+ for (const obs of observations) {
21811
+ const ageMs = now.getTime() - obs.ts;
21812
+ if (ageMs < 0) continue;
21813
+ const windowIdx = Math.floor(ageMs / dayMs);
21814
+ if (windowIdx > BASELINE_WINDOWS4) continue;
21815
+ let w = perTool.get(obs.tool);
21816
+ if (!w) {
21817
+ w = {
21818
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
21819
+ count: 0,
21820
+ evidenceIds: []
21821
+ })),
21822
+ knownSignatures: /* @__PURE__ */ new Set()
21823
+ };
21824
+ perTool.set(obs.tool, w);
21825
+ }
21826
+ const bucket = w.windows[windowIdx];
21827
+ bucket.count += 1;
21828
+ if (windowIdx === 0 && bucket.evidenceIds.length < 50) {
21829
+ bucket.evidenceIds.push(`${obs.entry.timestamp}:${obs.entry.operation}`);
21830
+ }
21831
+ }
21832
+ const findings = [];
21833
+ for (const [tool, w] of perTool.entries()) {
21834
+ const f = this.evaluateToolFrequency(tool, w, now);
21835
+ if (f) findings.push(f);
21836
+ }
21837
+ return findings;
21838
+ }
21839
+ evaluateToolFrequency(tool, w, now) {
21840
+ const current = w.windows[0];
21841
+ const baseline = w.windows.slice(1);
21842
+ const populated = baseline.filter((b) => b.count > 0).length;
21843
+ if (populated < BASELINE_WINDOWS4) {
21844
+ this.baselineEstablished.add(tool);
21845
+ return null;
21846
+ }
21847
+ const counts = baseline.map((b) => b.count);
21848
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
21849
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
21850
+ const stddev = Math.sqrt(variance);
21851
+ const wasEstablished = this.baselineEstablished.has(tool);
21852
+ this.baselineEstablished.add(tool);
21853
+ if (!wasEstablished) {
21854
+ return {
21855
+ finding_id: "",
21856
+ sentinel_id: this.sentinelId,
21857
+ severity: "info",
21858
+ summary: truncateSummary2(
21859
+ `${tool}: tool-call baseline established (mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} over ${BASELINE_WINDOWS4} prior days).`
21860
+ ),
21861
+ details: {
21862
+ layer: "B",
21863
+ tool,
21864
+ baseline_mean: mean,
21865
+ baseline_stddev: stddev,
21866
+ baseline_counts: counts,
21867
+ current_count: current.count
21868
+ },
21869
+ observed_at: now.toISOString(),
21870
+ evidence_audit_ids: [],
21871
+ fortress_id: ""
21872
+ };
21873
+ }
21874
+ const warnT = mean + WARN_SIGMA4 * stddev;
21875
+ const alertT = mean + ALERT_SIGMA4 * stddev;
21876
+ if (current.count > alertT) {
21877
+ return this.buildLayerBAnomaly(
21878
+ tool,
21879
+ current,
21880
+ mean,
21881
+ stddev,
21882
+ ALERT_SIGMA4,
21883
+ "alert",
21884
+ now
21885
+ );
21886
+ }
21887
+ if (current.count > warnT) {
21888
+ return this.buildLayerBAnomaly(
21889
+ tool,
21890
+ current,
21891
+ mean,
21892
+ stddev,
21893
+ WARN_SIGMA4,
21894
+ "warn",
21895
+ now
21896
+ );
21897
+ }
21898
+ return null;
21899
+ }
21900
+ buildLayerBAnomaly(tool, current, mean, stddev, sigma, severity, now) {
21901
+ const ratio = mean === 0 ? Infinity : current.count / mean;
21902
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21903
+ return {
21904
+ finding_id: "",
21905
+ sentinel_id: this.sentinelId,
21906
+ severity,
21907
+ summary: truncateSummary2(
21908
+ `${tool}: tool-call rate is ${ratioStr}: ${current.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma.`
21909
+ ),
21910
+ details: {
21911
+ layer: "B",
21912
+ tool,
21913
+ current_count: current.count,
21914
+ baseline_mean: mean,
21915
+ baseline_stddev: stddev,
21916
+ sigma_threshold: sigma,
21917
+ ratio
21918
+ },
21919
+ observed_at: now.toISOString(),
21920
+ evidence_audit_ids: current.evidenceIds,
21921
+ fortress_id: ""
21922
+ };
21923
+ }
21924
+ // ── Layer C ───────────────────────────────────────────────────────
21925
+ runLayerC(observations, now) {
21926
+ const dayMs = 24 * 60 * 60 * 1e3;
21927
+ const sorted = [...observations].sort((a, b) => a.ts - b.ts);
21928
+ const tasks = [];
21929
+ for (const obs of sorted) {
21930
+ const last = tasks[tasks.length - 1];
21931
+ if (!last || obs.ts - last.startTs > TASK_WINDOW_MS) {
21932
+ tasks.push({ startTs: obs.ts, tools: [obs.tool] });
21933
+ continue;
21934
+ }
21935
+ if (!last.tools.includes(obs.tool)) last.tools.push(obs.tool);
21936
+ }
21937
+ const recentTaskKeys = [];
21938
+ const findings = [];
21939
+ for (const task of tasks) {
21940
+ const ageMs = now.getTime() - task.startTs;
21941
+ const key = task.tools.slice().sort().join(",");
21942
+ if (ageMs > dayMs) {
21943
+ this.knownCombinations.add(key);
21944
+ continue;
21945
+ }
21946
+ if (task.tools.length < 2) continue;
21947
+ if (!this.knownCombinations.has(key)) {
21948
+ this.knownCombinations.add(key);
21949
+ if (!this.novelCombinationsReported.has(key)) {
21950
+ this.novelCombinationsReported.add(key);
21951
+ recentTaskKeys.push(key);
21952
+ findings.push(
21953
+ this.buildLayerCFinding(task, key, "warn", now)
21954
+ );
21955
+ }
21956
+ }
21957
+ }
21958
+ if (recentTaskKeys.length >= ALERT_NOVEL_COMBINATIONS) {
21959
+ const aggregate = {
21960
+ finding_id: "",
21961
+ sentinel_id: this.sentinelId,
21962
+ severity: "alert",
21963
+ summary: truncateSummary2(
21964
+ `multi-novel-combination: ${recentTaskKeys.length} novel tool-permission combinations within last 24h.`
21965
+ ),
21966
+ details: {
21967
+ layer: "C",
21968
+ novel_combinations: recentTaskKeys
21969
+ },
21970
+ observed_at: now.toISOString(),
21971
+ evidence_audit_ids: [],
21972
+ fortress_id: ""
21973
+ };
21974
+ findings.push(aggregate);
21975
+ }
21976
+ return findings;
21977
+ }
21978
+ buildLayerCFinding(task, key, severity, now) {
21979
+ return {
21980
+ finding_id: "",
21981
+ sentinel_id: this.sentinelId,
21982
+ severity,
21983
+ summary: truncateSummary2(
21984
+ `novel-permission-combination: tools=[${task.tools.join(",")}] observed in single task burst (${task.tools.length} distinct tools).`
21985
+ ),
21986
+ details: {
21987
+ layer: "C",
21988
+ combination_key: key,
21989
+ tools: task.tools,
21990
+ task_started_at: new Date(task.startTs).toISOString()
21991
+ },
21992
+ observed_at: now.toISOString(),
21993
+ evidence_audit_ids: [],
21994
+ fortress_id: ""
21995
+ };
21996
+ }
21997
+ // ── LLM-assist ────────────────────────────────────────────────────
21998
+ classifyHandle(ctx) {
21999
+ const selector = ctx.substrateSelector;
22000
+ if (!selector) return null;
22001
+ const fn = selector.invokeClassify;
22002
+ if (typeof fn !== "function") return null;
22003
+ return async (items) => {
22004
+ try {
22005
+ const resp = await fn.call(selector, "sentinel-scoring", {
22006
+ kind: "classify",
22007
+ items,
22008
+ categories: ["benign", "suspicious"]
22009
+ });
22010
+ if (resp.failureClass) return { kind: "failure", message: "substrate failure" };
22011
+ if (resp.body.kind === "classify") {
22012
+ return { kind: "classify", results: resp.body.results };
22013
+ }
22014
+ return { kind: "failure", message: resp.body.message };
22015
+ } catch {
22016
+ return null;
22017
+ }
22018
+ };
22019
+ }
22020
+ async consultClassifier(classify, obs) {
22021
+ const item = JSON.stringify({
22022
+ tool: obs.tool,
22023
+ proxy: obs.proxy,
22024
+ args_summary: obs.argsSummary
22025
+ });
22026
+ const result = await classify([item]);
22027
+ if (!result || result.kind !== "classify") return "unknown";
22028
+ const top = result.results[0];
22029
+ if (!top) return "unknown";
22030
+ if (top.category === "suspicious" && top.confidence >= 0.5) {
22031
+ return "suspicious";
22032
+ }
22033
+ if (top.category === "benign") return "benign";
22034
+ return "unknown";
22035
+ }
22036
+ // ── helpers ───────────────────────────────────────────────────────
22037
+ observationFromEntry(entry) {
22038
+ const op = entry.operation;
22039
+ let tool = null;
22040
+ let proxy = false;
22041
+ for (const prefix of GATE_PREFIXES) {
22042
+ if (op.startsWith(prefix)) {
22043
+ tool = op.slice(prefix.length);
22044
+ proxy = prefix === "gate_allow_proxy:";
22045
+ break;
22046
+ }
22047
+ }
22048
+ if (!tool) return null;
22049
+ const ts = Date.parse(entry.timestamp);
22050
+ if (!Number.isFinite(ts)) return null;
22051
+ const argsSummary = extractArgsSummary(entry.details);
22052
+ return { tool, proxy, ts, entry, argsSummary };
22053
+ }
22054
+ };
22055
+ function countTruncatedValues(args) {
22056
+ let n = 0;
22057
+ for (const v of Object.values(args)) {
22058
+ if (typeof v === "string" && v.endsWith("...")) n += 1;
22059
+ }
22060
+ return n;
22061
+ }
22062
+ function countUrlEncoded(value) {
22063
+ const matches = value.match(/%[0-9a-fA-F]{2}/g);
22064
+ return matches ? matches.length : 0;
22065
+ }
22066
+ function longestBase64Run(value) {
22067
+ const matches = value.match(/[A-Za-z0-9+/=]{40,}/g);
22068
+ if (!matches) return 0;
22069
+ return matches.reduce((max, m) => m.length > max ? m.length : max, 0);
22070
+ }
22071
+ function extractArgsSummary(details) {
22072
+ if (!details) return {};
22073
+ const summary = details["args_summary"];
22074
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
22075
+ return summary;
22076
+ }
22077
+ return {};
22078
+ }
22079
+ function truncateSummary2(s) {
22080
+ return s.length > 240 ? s.slice(0, 237) + "..." : s;
22081
+ }
22082
+
22083
+ // src/sentinel/sentinels/index.ts
22084
+ var PHI1_BASELINE_CATALOG = [
22085
+ {
22086
+ sentinelId: EGRESS_VOLUME_SENTINEL_ID,
22087
+ description: "Watches outbound proxy-call volume per upstream server and surfaces anomalous spikes against a rolling 7-day baseline.",
22088
+ factory: () => new EgressVolumeWatcher()
22089
+ },
22090
+ {
22091
+ sentinelId: CROSS_AGENT_CHATTER_SENTINEL_ID,
22092
+ description: "Watches inter-agent communication patterns. Surfaces per-pair rate spikes (3 or 6 sigma over the rolling 7-day baseline) and new-partner appearances. Escalates to alert when one source agent picks up 3 or more new partners in 24h.",
22093
+ factory: () => new CrossAgentChatterWatcher()
22094
+ },
22095
+ {
22096
+ sentinelId: CREDENTIAL_USAGE_SENTINEL_ID,
22097
+ description: "Watches per-agent credential reads. Surfaces (agent, secret) usage that exceeds a rolling 7-day baseline, and unfamiliar secret combinations the agent uses for the first time in one 24h window.",
22098
+ factory: () => new CredentialUsageWatcher()
22099
+ },
22100
+ {
22101
+ sentinelId: SUSPICIOUS_TOOL_CALL_SENTINEL_ID,
22102
+ description: "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history.",
22103
+ factory: () => new SuspiciousToolCallDetector()
22104
+ }
22105
+ ];
22106
+ var FILE_VERSION = 1;
22107
+ function sentinelSubscriptionsPath(storagePath) {
22108
+ return join(storagePath, "sentinel-subscriptions.json");
22109
+ }
22110
+ async function loadSentinelSubscriptions(storagePath) {
22111
+ const filePath = sentinelSubscriptionsPath(storagePath);
22112
+ try {
22113
+ const raw = await readFile(filePath, "utf8");
22114
+ const parsed = JSON.parse(raw);
22115
+ if (parsed.version !== FILE_VERSION) return /* @__PURE__ */ new Set();
22116
+ if (!Array.isArray(parsed.subscribed)) return /* @__PURE__ */ new Set();
22117
+ const cleaned = parsed.subscribed.filter(
22118
+ (id) => typeof id === "string" && id.length > 0
22119
+ );
22120
+ return new Set(cleaned);
22121
+ } catch {
22122
+ return /* @__PURE__ */ new Set();
22123
+ }
22124
+ }
22125
+
20175
22126
  // src/principal-policy/tools.ts
20176
22127
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
20177
22128
  return [
@@ -32313,6 +34264,13 @@ init_encoding();
32313
34264
  // src/chat/operator-chat-audit-events.ts
32314
34265
  var OPERATOR_CHAT_OPS = {
32315
34266
  CONCIERGE_CHAT: "operator_concierge_chat",
34267
+ /**
34268
+ * Click-to-inspect panel opened on an agent row. Repurposed from the
34269
+ * direct-agent session-open audit event in the v1.2 reshape; the click
34270
+ * affordance now opens an inspect/approve panel (recent activity +
34271
+ * pending approvals + policy summary) instead of a chat session.
34272
+ */
34273
+ AGENT_INSPECT_PANEL_OPENED: "agent_inspect_panel_opened",
32316
34274
  /**
32317
34275
  * Operator viewed concierge thread history (WP-V1.3-9 Tau-1). Emitted
32318
34276
  * when the operator hits the list-threads or read-thread route. Body
@@ -32339,7 +34297,17 @@ var OPERATOR_CHAT_OPS = {
32339
34297
  * fold. The concierge omits that category and continues; the user-
32340
34298
  * facing query is never broken. Body carries category + failure_reason.
32341
34299
  */
32342
- CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed"
34300
+ CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed",
34301
+ /**
34302
+ * Concierge surfaced a proactive starter when a fresh conversation
34303
+ * thread opened (WP-V1.3-9 Tau-5). Emitted once per starter, never on
34304
+ * follow-up turns within the same thread. Body carries `thread_id`,
34305
+ * `trigger` (stable enum), and `triggered_agents_count`. The starter
34306
+ * text body is NOT carried; the trigger enum is sufficient for
34307
+ * dashboard grouping and keeps fortress-internal agent ids off the
34308
+ * audit surface.
34309
+ */
34310
+ CONCIERGE_PROACTIVE_SUGGESTION_OFFERED: "operator_concierge_proactive_suggestion_offered"
32343
34311
  };
32344
34312
 
32345
34313
  // src/chat/operator-chat-types.ts
@@ -32481,9 +34449,10 @@ function isTrivialQuery(query) {
32481
34449
  if (norm.length < 8) return true;
32482
34450
  return TRIVIAL_GREETINGS.has(norm);
32483
34451
  }
32484
- function classifyQuery(query) {
34452
+ function classifyQuery(query, parsedGrammar) {
32485
34453
  const normalized = query.toLowerCase();
32486
34454
  const matches = [];
34455
+ const grammarAgent = parsedGrammar && parsedGrammar.agent_names.length > 0 ? parsedGrammar.agent_names[0] ?? null : null;
32487
34456
  for (const spec of CATEGORY_KEYWORDS) {
32488
34457
  const matchedPhrases = [];
32489
34458
  for (const pattern of spec.patterns) {
@@ -32495,11 +34464,14 @@ function classifyQuery(query) {
32495
34464
  }
32496
34465
  if (matchedPhrases.length === 0) continue;
32497
34466
  const confidence = Math.min(1, 0.4 + 0.3 * matchedPhrases.length);
34467
+ const wantsAgentHint = spec.category === "agent_state" || spec.category === "agent_activity";
34468
+ const agent_name_hint = wantsAgentHint ? grammarAgent ?? extractAgentNameHint(query) : null;
32498
34469
  matches.push({
32499
34470
  category: spec.category,
32500
34471
  confidence,
32501
34472
  matched_keywords: matchedPhrases,
32502
- agent_name_hint: spec.category === "agent_state" || spec.category === "agent_activity" ? extractAgentNameHint(query) : null
34473
+ agent_name_hint,
34474
+ ...parsedGrammar !== void 0 ? { query_grammar: parsedGrammar } : {}
32503
34475
  });
32504
34476
  }
32505
34477
  matches.sort((a, b) => {
@@ -32508,6 +34480,25 @@ function classifyQuery(query) {
32508
34480
  });
32509
34481
  return matches;
32510
34482
  }
34483
+ function fetcherHintsFromGrammar(parsed) {
34484
+ if (!parsed) return void 0;
34485
+ const hasTime = parsed.time_range !== null;
34486
+ const hasAgents = parsed.agent_names.length > 0;
34487
+ const hasEvents = parsed.event_types.length > 0;
34488
+ if (!hasTime && !hasAgents && !hasEvents) return void 0;
34489
+ const hints = {};
34490
+ if (parsed.time_range) {
34491
+ const range = parsed.time_range;
34492
+ hints.time_range = {
34493
+ start: range.start,
34494
+ end: range.end,
34495
+ ...range.relative_label !== void 0 ? { relative_label: range.relative_label } : {}
34496
+ };
34497
+ }
34498
+ if (hasAgents) hints.agent_names = parsed.agent_names;
34499
+ if (hasEvents) hints.event_types = parsed.event_types;
34500
+ return hints;
34501
+ }
32511
34502
  function approxTokenLen(text) {
32512
34503
  return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
32513
34504
  }
@@ -32521,42 +34512,45 @@ var CATEGORY_LABELS = {
32521
34512
  recent_receipts: "Recent receipts",
32522
34513
  verascore_deltas: "Verascore deltas"
32523
34514
  };
32524
- async function runFetcher(match, fetchers) {
34515
+ async function runFetcher(match, fetchers, hints) {
32525
34516
  switch (match.category) {
32526
34517
  case "templates":
32527
- return fetchers.templates();
34518
+ return fetchers.templates(hints);
32528
34519
  case "agent_state":
32529
- return fetchers.agent_state(match.agent_name_hint);
34520
+ return fetchers.agent_state(match.agent_name_hint, hints);
32530
34521
  case "agent_activity":
32531
- return fetchers.agent_activity(match.agent_name_hint);
34522
+ return fetchers.agent_activity(match.agent_name_hint, hints);
32532
34523
  case "audit_log":
32533
- return fetchers.audit_log();
34524
+ return fetchers.audit_log(hints);
32534
34525
  case "sentinel_findings":
32535
- return fetchers.sentinel_findings();
34526
+ return fetchers.sentinel_findings(hints);
32536
34527
  case "anomaly_alerts":
32537
- return fetchers.anomaly_alerts();
34528
+ return fetchers.anomaly_alerts(hints);
32538
34529
  case "recent_receipts":
32539
- return fetchers.recent_receipts();
34530
+ return fetchers.recent_receipts(hints);
32540
34531
  case "verascore_deltas":
32541
- return fetchers.verascore_deltas();
34532
+ return fetchers.verascore_deltas(hints);
32542
34533
  }
32543
34534
  }
32544
- function trivialMatch(category) {
34535
+ function trivialMatch(category, parsedGrammar) {
32545
34536
  return {
32546
34537
  category,
32547
34538
  confidence: 0.5,
32548
34539
  matched_keywords: ["llm-assist"],
32549
- agent_name_hint: null
34540
+ agent_name_hint: parsedGrammar && parsedGrammar.agent_names.length > 0 ? parsedGrammar.agent_names[0] ?? null : null,
34541
+ ...parsedGrammar !== void 0 ? { query_grammar: parsedGrammar } : {}
32550
34542
  };
32551
34543
  }
32552
34544
  async function foldContext(query, fetchers, opts) {
32553
34545
  const budget = opts?.maxTokens ?? DEFAULT_DYNAMIC_CONTEXT_TOKEN_BUDGET;
32554
- let matches = classifyQuery(query);
34546
+ const parsed = opts?.parsed ?? null;
34547
+ const hints = fetcherHintsFromGrammar(parsed);
34548
+ let matches = classifyQuery(query, parsed);
32555
34549
  if (matches.length === 0 && !isTrivialQuery(query) && opts?.llmAssistClassify) {
32556
34550
  try {
32557
34551
  const picked = await opts.llmAssistClassify(query, CONTEXT_CATEGORIES);
32558
34552
  if (picked !== "none" && CONTEXT_CATEGORIES.includes(picked)) {
32559
- matches = [trivialMatch(picked)];
34553
+ matches = [trivialMatch(picked, parsed)];
32560
34554
  }
32561
34555
  } catch {
32562
34556
  }
@@ -32567,7 +34561,7 @@ async function foldContext(query, fetchers, opts) {
32567
34561
  const attempts = [];
32568
34562
  for (const match of matches) {
32569
34563
  try {
32570
- const text = await runFetcher(match, fetchers);
34564
+ const text = await runFetcher(match, fetchers, hints);
32571
34565
  const trimmed = text.trim();
32572
34566
  if (trimmed.length > 0) {
32573
34567
  attempts.push({ category: match.category, text: trimmed });
@@ -32609,6 +34603,614 @@ ${blocks.join("\n\n")}`;
32609
34603
  };
32610
34604
  }
32611
34605
 
34606
+ // src/composition/constants.ts
34607
+ var COMPOSITION_EVENT_TYPES = [
34608
+ "composition_receipt_packed",
34609
+ "composition_receipt_verified",
34610
+ "composition_mandate_verified",
34611
+ "composition_verascore_published",
34612
+ "composition_sidecar_spawned",
34613
+ "composition_sidecar_crashed",
34614
+ "composition_sidecar_recovered",
34615
+ "composition_degraded",
34616
+ "composition_recovered"
34617
+ ];
34618
+
34619
+ // src/chat/concierge-query-grammar.ts
34620
+ var CANONICAL_AUDIT_EVENT_CLASSES = [
34621
+ // Lifecycle / policy
34622
+ "policy_change",
34623
+ "approval_request",
34624
+ "audit_truncate",
34625
+ "lockdown",
34626
+ "unwrap",
34627
+ // Exit bundle (Tier 1)
34628
+ "exit_bundle_export",
34629
+ "exit_bundle_import_activate",
34630
+ "exit_bundle_rekey",
34631
+ // Cross-harness approval aggregator
34632
+ "cross_harness_approval_aggregated",
34633
+ "cross_harness_approval_resolved",
34634
+ "cross_harness_approval_deduped",
34635
+ "cross_harness_approval_payload_decrypted",
34636
+ "cross_harness_approval_audit_trail_viewed",
34637
+ "cross_harness_approval_replayed",
34638
+ // Composition (full set from constants.ts)
34639
+ ...COMPOSITION_EVENT_TYPES,
34640
+ // Operator chat / concierge (full set from OPERATOR_CHAT_OPS)
34641
+ OPERATOR_CHAT_OPS.CONCIERGE_CHAT,
34642
+ OPERATOR_CHAT_OPS.AGENT_INSPECT_PANEL_OPENED,
34643
+ OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ,
34644
+ OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED,
34645
+ OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED,
34646
+ OPERATOR_CHAT_OPS.CONCIERGE_CONTEXT_FETCHER_FAILED,
34647
+ // Bridge / commitment
34648
+ "bridge_commit",
34649
+ "bridge_verify",
34650
+ "bridge_attest",
34651
+ "proof_commitment",
34652
+ "proof_reveal",
34653
+ // Reputation
34654
+ "reputation_export",
34655
+ "reputation_import",
34656
+ "reputation_publish",
34657
+ "reputation_record",
34658
+ "reputation_query"
34659
+ ];
34660
+ var EVENT_SYNONYMS = [
34661
+ { phrase: "approvals", canonical: ["approval_request", "cross_harness_approval_aggregated", "cross_harness_approval_resolved"] },
34662
+ { phrase: "approval", canonical: ["approval_request"] },
34663
+ { phrase: "policy changes", canonical: ["policy_change"] },
34664
+ { phrase: "policy change", canonical: ["policy_change"] },
34665
+ { phrase: "policy edits", canonical: ["policy_change"] },
34666
+ { phrase: "lockdowns", canonical: ["lockdown"] },
34667
+ { phrase: "exit bundles", canonical: ["exit_bundle_export", "exit_bundle_import_activate"] },
34668
+ { phrase: "exit bundle", canonical: ["exit_bundle_export"] },
34669
+ { phrase: "audit truncations", canonical: ["audit_truncate"] },
34670
+ { phrase: "audit truncation", canonical: ["audit_truncate"] },
34671
+ { phrase: "compositions", canonical: ["composition_receipt_packed", "composition_receipt_verified"] },
34672
+ { phrase: "receipts", canonical: ["composition_receipt_packed", "composition_receipt_verified"] },
34673
+ { phrase: "receipt verifications", canonical: ["composition_receipt_verified"] },
34674
+ { phrase: "concierge chats", canonical: [OPERATOR_CHAT_OPS.CONCIERGE_CHAT] },
34675
+ { phrase: "cross harness approvals", canonical: ["cross_harness_approval_aggregated", "cross_harness_approval_resolved"] }
34676
+ ];
34677
+ var MS_PER_HOUR = 60 * 60 * 1e3;
34678
+ var MS_PER_DAY = 24 * MS_PER_HOUR;
34679
+ var NUMBER_WORDS = {
34680
+ a: 1,
34681
+ an: 1,
34682
+ one: 1,
34683
+ two: 2,
34684
+ three: 3,
34685
+ four: 4,
34686
+ five: 5,
34687
+ six: 6,
34688
+ seven: 7,
34689
+ eight: 8,
34690
+ nine: 9,
34691
+ ten: 10,
34692
+ twelve: 12,
34693
+ twentyfour: 24
34694
+ };
34695
+ function resolveTimeRange(query, now) {
34696
+ const normalized = query.trim();
34697
+ const lower = normalized.toLowerCase();
34698
+ const fromTo = lower.match(
34699
+ /\b(?:from|between)\s+(.+?)\s+(?:to|and|-|until)\s+([\w:.\-+t /]+)/i
34700
+ );
34701
+ if (fromTo) {
34702
+ const aSlice = fromTo[1];
34703
+ const bSlice = fromTo[2];
34704
+ if (aSlice !== void 0 && bSlice !== void 0) {
34705
+ const a = parseInstant(aSlice, now);
34706
+ const b = parseInstant(bSlice, now);
34707
+ if (a && b) {
34708
+ const start = a.getTime() <= b.getTime() ? a : b;
34709
+ const end = a.getTime() <= b.getTime() ? b : a;
34710
+ return {
34711
+ range: { start, end },
34712
+ matchedSubstring: fromTo[0]
34713
+ };
34714
+ }
34715
+ }
34716
+ }
34717
+ const sinceMatch = lower.match(/\bsince\s+([\w:.\-+t /]+)/i);
34718
+ if (sinceMatch) {
34719
+ const slice = sinceMatch[1];
34720
+ if (slice !== void 0) {
34721
+ const start = parseInstant(slice, now);
34722
+ if (start) {
34723
+ return {
34724
+ range: { start, end: now },
34725
+ matchedSubstring: sinceMatch[0]
34726
+ };
34727
+ }
34728
+ }
34729
+ }
34730
+ if (/\byesterday\b/.test(lower)) {
34731
+ const startOfToday = startOfDay(now);
34732
+ const start = new Date(startOfToday.getTime() - MS_PER_DAY);
34733
+ const end = new Date(startOfToday.getTime() - 1);
34734
+ return {
34735
+ range: { start, end, relative_label: "yesterday" },
34736
+ matchedSubstring: "yesterday"
34737
+ };
34738
+ }
34739
+ if (/\btoday\b/.test(lower)) {
34740
+ return {
34741
+ range: {
34742
+ start: startOfDay(now),
34743
+ end: now,
34744
+ relative_label: "today"
34745
+ },
34746
+ matchedSubstring: "today"
34747
+ };
34748
+ }
34749
+ const compactHours = lower.match(/\blast\s+(\d+)\s*h\b/i);
34750
+ if (compactHours) {
34751
+ const tok = compactHours[1];
34752
+ if (tok !== void 0) {
34753
+ const n = Number.parseInt(tok, 10);
34754
+ if (Number.isFinite(n) && n > 0) {
34755
+ const start = new Date(now.getTime() - n * MS_PER_HOUR);
34756
+ return {
34757
+ range: { start, end: now, relative_label: `last ${n}h` },
34758
+ matchedSubstring: compactHours[0]
34759
+ };
34760
+ }
34761
+ }
34762
+ }
34763
+ const hoursMatch = lower.match(
34764
+ /\b(?:past|last)\s+([\w]+|\d+)\s*(?:hr\b|hrs\b|hour|hours)/i
34765
+ );
34766
+ if (hoursMatch) {
34767
+ const tok = hoursMatch[1];
34768
+ if (tok !== void 0) {
34769
+ const n = parseCount(tok);
34770
+ if (n !== null && n > 0) {
34771
+ const start = new Date(now.getTime() - n * MS_PER_HOUR);
34772
+ return {
34773
+ range: { start, end: now, relative_label: `past ${n} hour${n === 1 ? "" : "s"}` },
34774
+ matchedSubstring: hoursMatch[0]
34775
+ };
34776
+ }
34777
+ }
34778
+ }
34779
+ if (/\b(?:past|last)\s+hour\b/.test(lower)) {
34780
+ const start = new Date(now.getTime() - MS_PER_HOUR);
34781
+ return {
34782
+ range: { start, end: now, relative_label: "past hour" },
34783
+ matchedSubstring: lower.match(/\b(?:past|last)\s+hour\b/i)[0]
34784
+ };
34785
+ }
34786
+ const daysMatch = lower.match(
34787
+ /\b(?:past|last)\s+([\w]+|\d+)\s*(?:d\b|day|days)/i
34788
+ );
34789
+ if (daysMatch) {
34790
+ const tok = daysMatch[1];
34791
+ if (tok !== void 0) {
34792
+ const n = parseCount(tok);
34793
+ if (n !== null && n > 0) {
34794
+ const start = new Date(now.getTime() - n * MS_PER_DAY);
34795
+ return {
34796
+ range: { start, end: now, relative_label: `past ${n} day${n === 1 ? "" : "s"}` },
34797
+ matchedSubstring: daysMatch[0]
34798
+ };
34799
+ }
34800
+ }
34801
+ }
34802
+ if (/\b(?:past|last)\s+day\b/.test(lower)) {
34803
+ const start = new Date(now.getTime() - MS_PER_DAY);
34804
+ return {
34805
+ range: { start, end: now, relative_label: "past day" },
34806
+ matchedSubstring: lower.match(/\b(?:past|last)\s+day\b/i)[0]
34807
+ };
34808
+ }
34809
+ if (/\bthis\s+week\b/.test(lower)) {
34810
+ const start = startOfWeek(now);
34811
+ return {
34812
+ range: { start, end: now, relative_label: "this week" },
34813
+ matchedSubstring: lower.match(/\bthis\s+week\b/i)[0]
34814
+ };
34815
+ }
34816
+ if (/\b(?:past|last)\s+week\b/.test(lower)) {
34817
+ const start = new Date(now.getTime() - 7 * MS_PER_DAY);
34818
+ return {
34819
+ range: { start, end: now, relative_label: "past week" },
34820
+ matchedSubstring: lower.match(/\b(?:past|last)\s+week\b/i)[0]
34821
+ };
34822
+ }
34823
+ const isoMatch = normalized.match(
34824
+ /\b(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?)?)\b/
34825
+ );
34826
+ if (isoMatch) {
34827
+ const tok = isoMatch[1];
34828
+ if (tok !== void 0) {
34829
+ const parsed = parseInstant(tok, now);
34830
+ if (parsed) {
34831
+ const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(tok);
34832
+ if (isDateOnly) {
34833
+ return {
34834
+ range: {
34835
+ start: parsed,
34836
+ end: new Date(parsed.getTime() + MS_PER_DAY - 1)
34837
+ },
34838
+ matchedSubstring: tok
34839
+ };
34840
+ }
34841
+ return {
34842
+ range: {
34843
+ start: new Date(parsed.getTime() - 30 * 60 * 1e3),
34844
+ end: new Date(parsed.getTime() + 30 * 60 * 1e3)
34845
+ },
34846
+ matchedSubstring: tok
34847
+ };
34848
+ }
34849
+ }
34850
+ }
34851
+ return null;
34852
+ }
34853
+ function parseInstant(token, now) {
34854
+ const trimmed = token.trim().replace(/[,.!?;]+$/g, "");
34855
+ if (!trimmed) return null;
34856
+ const lower = trimmed.toLowerCase();
34857
+ if (lower === "now") return now;
34858
+ if (lower === "today") return startOfDay(now);
34859
+ if (lower === "yesterday") {
34860
+ return new Date(startOfDay(now).getTime() - MS_PER_DAY);
34861
+ }
34862
+ const isoLike = trimmed.replace(" ", "T");
34863
+ const parsed = new Date(isoLike);
34864
+ if (!Number.isNaN(parsed.getTime())) return parsed;
34865
+ return null;
34866
+ }
34867
+ function parseCount(token) {
34868
+ const lower = token.toLowerCase();
34869
+ if (/^\d+$/.test(lower)) {
34870
+ const n = Number.parseInt(lower, 10);
34871
+ return Number.isFinite(n) ? n : null;
34872
+ }
34873
+ return NUMBER_WORDS[lower] ?? null;
34874
+ }
34875
+ function startOfDay(d) {
34876
+ const out = new Date(d);
34877
+ out.setHours(0, 0, 0, 0);
34878
+ return out;
34879
+ }
34880
+ function startOfWeek(d) {
34881
+ const out = startOfDay(d);
34882
+ const dayOfWeek = out.getDay();
34883
+ const offsetToMonday = (dayOfWeek + 6) % 7;
34884
+ out.setDate(out.getDate() - offsetToMonday);
34885
+ return out;
34886
+ }
34887
+ function listFromRegistry(registry) {
34888
+ if (!registry) return [];
34889
+ if (Array.isArray(registry)) return registry;
34890
+ if (typeof registry.list === "function") {
34891
+ return registry.list();
34892
+ }
34893
+ return [];
34894
+ }
34895
+ function extractAgentNames(query, registry) {
34896
+ const records = listFromRegistry(registry);
34897
+ if (records.length === 0) return { matched: [], flagged: false };
34898
+ const lowerQuery = query.toLowerCase();
34899
+ const compactQuery = lowerQuery.replace(/[\s_-]+/g, "");
34900
+ const matched = [];
34901
+ const seen = /* @__PURE__ */ new Set();
34902
+ for (const rec of records) {
34903
+ const id = rec.agent_id;
34904
+ if (!id || seen.has(id)) continue;
34905
+ const idLower = id.toLowerCase();
34906
+ if (idLower.length < 3) continue;
34907
+ const idCompact = idLower.replace(/[\s_-]+/g, "");
34908
+ const wordRe = new RegExp(
34909
+ `\\b${idLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
34910
+ "i"
34911
+ );
34912
+ if (wordRe.test(query)) {
34913
+ matched.push(id);
34914
+ seen.add(id);
34915
+ continue;
34916
+ }
34917
+ if (idCompact.length >= 4 && compactQuery.includes(idCompact)) {
34918
+ matched.push(id);
34919
+ seen.add(id);
34920
+ }
34921
+ }
34922
+ const agentMention = lowerQuery.match(/\bagent\s+([a-z0-9_-]{3,40})/i);
34923
+ const flagged = matched.length === 0 && agentMention !== null && agentMention[1] !== void 0 && !records.some((r) => r.agent_id.toLowerCase() === agentMention[1]?.toLowerCase());
34924
+ return { matched, flagged };
34925
+ }
34926
+ function escapeRegex(s) {
34927
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34928
+ }
34929
+ function extractEventTypes(query, enumValues) {
34930
+ const lower = query.toLowerCase();
34931
+ const matched = [];
34932
+ const seen = /* @__PURE__ */ new Set();
34933
+ for (const ev of enumValues) {
34934
+ if (seen.has(ev)) continue;
34935
+ const re = new RegExp(`\\b${escapeRegex(ev)}\\b`, "i");
34936
+ if (re.test(query)) {
34937
+ matched.push(ev);
34938
+ seen.add(ev);
34939
+ }
34940
+ }
34941
+ for (const syn of EVENT_SYNONYMS) {
34942
+ const re = new RegExp(
34943
+ `\\b${syn.phrase.split(/\s+/).map(escapeRegex).join("\\s+")}\\b`,
34944
+ "i"
34945
+ );
34946
+ if (re.test(query)) {
34947
+ for (const c of syn.canonical) {
34948
+ if (seen.has(c)) continue;
34949
+ if (!enumValues.includes(c)) continue;
34950
+ matched.push(c);
34951
+ seen.add(c);
34952
+ }
34953
+ }
34954
+ }
34955
+ const globMatches = lower.match(/\b([a-z_]+)_\*/g) ?? [];
34956
+ for (const glob of globMatches) {
34957
+ const prefix = glob.slice(0, -2);
34958
+ for (const ev of enumValues) {
34959
+ if (seen.has(ev)) continue;
34960
+ if (ev.startsWith(prefix)) {
34961
+ matched.push(ev);
34962
+ seen.add(ev);
34963
+ }
34964
+ }
34965
+ }
34966
+ const eventNounMention = /\b(?:event|events|class|classes)\b/i.test(query) && matched.length === 0;
34967
+ return { matched, flagged: eventNounMention };
34968
+ }
34969
+ function deriveIntentPhrase(query, stripTokens) {
34970
+ let out = query;
34971
+ for (const tok of stripTokens) {
34972
+ if (!tok) continue;
34973
+ const re = new RegExp(escapeRegex(tok), "gi");
34974
+ out = out.replace(re, " ");
34975
+ }
34976
+ return out.replace(/\s+/g, " ").trim();
34977
+ }
34978
+ function computeConfidence(parsed) {
34979
+ const dims = [
34980
+ { present: parsed.hasTimeMention, resolved: parsed.timeResolved },
34981
+ { present: parsed.hasAgentMention, resolved: parsed.agentResolved },
34982
+ { present: parsed.hasEventMention, resolved: parsed.eventResolved }
34983
+ ];
34984
+ const present = dims.filter((d) => d.present);
34985
+ let base;
34986
+ if (present.length === 0) {
34987
+ base = parsed.intentEmpty ? 0 : 0.3;
34988
+ } else {
34989
+ const resolved = present.filter((d) => d.resolved).length;
34990
+ base = resolved / present.length;
34991
+ }
34992
+ const adjusted = base - 0.15 * parsed.ambiguityCount;
34993
+ if (adjusted < 0) return 0;
34994
+ if (adjusted > 1) return 1;
34995
+ return adjusted;
34996
+ }
34997
+ var TIME_MENTION_PROBE = /\b(yesterday|today|now|past|last|this\s+week|this\s+month|since|from|between|\d{4}-\d{2}-\d{2})\b/i;
34998
+ var AGENT_MENTION_PROBE = /\bagent[s]?\b/i;
34999
+ var EVENT_MENTION_PROBE = /\b(event|events|class|classes|approvals?|policy)\b/i;
35000
+ function parseQuery(query, opts) {
35001
+ const now = opts?.now ?? /* @__PURE__ */ new Date();
35002
+ const enumValues = opts?.eventClassEnum ?? CANONICAL_AUDIT_EVENT_CLASSES;
35003
+ const original = query ?? "";
35004
+ const trimmed = original.trim();
35005
+ if (trimmed.length === 0) {
35006
+ return {
35007
+ time_range: null,
35008
+ agent_names: [],
35009
+ event_types: [],
35010
+ intent_phrase: "",
35011
+ ambiguity_flags: ["no_signal_extracted"],
35012
+ parse_confidence: 0
35013
+ };
35014
+ }
35015
+ const ambiguity_flags = /* @__PURE__ */ new Set();
35016
+ const timeMatch = resolveTimeRange(trimmed, now);
35017
+ const hasTimeMention = TIME_MENTION_PROBE.test(trimmed);
35018
+ if (hasTimeMention && !timeMatch) {
35019
+ ambiguity_flags.add("unknown_time_token");
35020
+ }
35021
+ const agentResult = extractAgentNames(trimmed, opts?.registry);
35022
+ if (agentResult.flagged) {
35023
+ ambiguity_flags.add("unknown_agent_token");
35024
+ }
35025
+ const hasAgentMention = AGENT_MENTION_PROBE.test(trimmed);
35026
+ const eventResult = extractEventTypes(trimmed, enumValues);
35027
+ const hasEventMention = EVENT_MENTION_PROBE.test(trimmed);
35028
+ if (eventResult.flagged) {
35029
+ ambiguity_flags.add("unknown_event_token");
35030
+ }
35031
+ const stripTokens = [];
35032
+ if (timeMatch) stripTokens.push(timeMatch.matchedSubstring);
35033
+ for (const name of agentResult.matched) stripTokens.push(name);
35034
+ for (const ev of eventResult.matched) {
35035
+ if (trimmed.toLowerCase().includes(ev.toLowerCase())) {
35036
+ stripTokens.push(ev);
35037
+ }
35038
+ }
35039
+ const intent_phrase = deriveIntentPhrase(trimmed, stripTokens);
35040
+ const parse_confidence = computeConfidence({
35041
+ hasTimeMention,
35042
+ timeResolved: timeMatch !== null,
35043
+ hasAgentMention,
35044
+ agentResolved: agentResult.matched.length > 0,
35045
+ hasEventMention,
35046
+ eventResolved: eventResult.matched.length > 0,
35047
+ intentEmpty: intent_phrase.length === 0,
35048
+ ambiguityCount: ambiguity_flags.size
35049
+ });
35050
+ if (timeMatch === null && agentResult.matched.length === 0 && eventResult.matched.length === 0 && intent_phrase.length === 0) {
35051
+ ambiguity_flags.add("no_signal_extracted");
35052
+ }
35053
+ return {
35054
+ time_range: timeMatch ? timeMatch.range : null,
35055
+ agent_names: agentResult.matched,
35056
+ event_types: eventResult.matched,
35057
+ intent_phrase,
35058
+ ambiguity_flags: Array.from(ambiguity_flags),
35059
+ parse_confidence
35060
+ };
35061
+ }
35062
+ var LLM_ASSIST_THRESHOLD = 0.5;
35063
+ function isLowConfidence(parsed) {
35064
+ return parsed.parse_confidence < LLM_ASSIST_THRESHOLD;
35065
+ }
35066
+ async function parseQueryWithLlmAssist(query, llmAssist, opts) {
35067
+ const parsed = parseQuery(query, opts);
35068
+ if (!llmAssist || !isLowConfidence(parsed)) return parsed;
35069
+ let completion;
35070
+ try {
35071
+ completion = await llmAssist(query, parsed);
35072
+ } catch {
35073
+ return parsed;
35074
+ }
35075
+ if (!completion || typeof completion !== "object") return parsed;
35076
+ const merged = { ...parsed };
35077
+ if (parsed.time_range === null && completion.time_range) {
35078
+ merged.time_range = completion.time_range;
35079
+ }
35080
+ if (parsed.agent_names.length === 0 && Array.isArray(completion.agent_names)) {
35081
+ merged.agent_names = completion.agent_names.filter(
35082
+ (s) => typeof s === "string" && s.length > 0
35083
+ );
35084
+ }
35085
+ if (parsed.event_types.length === 0 && Array.isArray(completion.event_types)) {
35086
+ const allowed = new Set(opts?.eventClassEnum ?? CANONICAL_AUDIT_EVENT_CLASSES);
35087
+ merged.event_types = completion.event_types.filter(
35088
+ (s) => typeof s === "string" && allowed.has(s)
35089
+ );
35090
+ }
35091
+ merged.parse_confidence = Math.max(
35092
+ parsed.parse_confidence,
35093
+ computeConfidence({
35094
+ hasTimeMention: TIME_MENTION_PROBE.test(query),
35095
+ timeResolved: merged.time_range !== null,
35096
+ hasAgentMention: AGENT_MENTION_PROBE.test(query),
35097
+ agentResolved: merged.agent_names.length > 0,
35098
+ hasEventMention: EVENT_MENTION_PROBE.test(query),
35099
+ eventResolved: merged.event_types.length > 0,
35100
+ intentEmpty: merged.intent_phrase.length === 0,
35101
+ ambiguityCount: merged.ambiguity_flags.length
35102
+ })
35103
+ );
35104
+ return merged;
35105
+ }
35106
+ function auditSafeSummary(parsed) {
35107
+ return {
35108
+ time_range: parsed.time_range ? {
35109
+ start_iso: parsed.time_range.start.toISOString(),
35110
+ end_iso: parsed.time_range.end.toISOString(),
35111
+ ...parsed.time_range.relative_label !== void 0 ? { relative_label: parsed.time_range.relative_label } : {}
35112
+ } : null,
35113
+ agent_names: [...parsed.agent_names],
35114
+ event_types: [...parsed.event_types],
35115
+ ambiguity_flags: [...parsed.ambiguity_flags],
35116
+ parse_confidence: parsed.parse_confidence
35117
+ };
35118
+ }
35119
+
35120
+ // src/chat/agent-context-cache.ts
35121
+ var STATE_FLAG_ORDER = [
35122
+ "stuck",
35123
+ "has_pending_approvals",
35124
+ "has_open_findings",
35125
+ "active",
35126
+ "idle"
35127
+ ];
35128
+ var SECTION_HEADER = "## Current agent state";
35129
+ function approxTokenLen2(text) {
35130
+ return Math.ceil(text.length / 4);
35131
+ }
35132
+ var DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET = 400;
35133
+ function formatSnapshotLine(snapshot) {
35134
+ const flagLabel = snapshot.state_flags.join("+") || "no_flags";
35135
+ const work = snapshot.current_work_summary ? `, last: ${snapshot.current_work_summary}` : "";
35136
+ const verascore = snapshot.recent_verascore_delta_24h !== null ? `, verascore \u0394${snapshot.recent_verascore_delta_24h.toFixed(2)}` : "";
35137
+ return `- ${snapshot.agent_name} (template: ${snapshot.template}): ${flagLabel}, ${snapshot.recent_audit_count_24h} audit/24h, ${snapshot.recent_concordia_receipts_count_24h} receipts${verascore}${work}`;
35138
+ }
35139
+ function urgencyRank(snapshot) {
35140
+ for (let i = 0; i < STATE_FLAG_ORDER.length; i++) {
35141
+ if (snapshot.state_flags.includes(STATE_FLAG_ORDER[i])) {
35142
+ return i;
35143
+ }
35144
+ }
35145
+ return STATE_FLAG_ORDER.length;
35146
+ }
35147
+ function formatCurrentAgentStateSection(snapshots, opts) {
35148
+ if (snapshots.length === 0) return "";
35149
+ const budget = opts?.maxTokens ?? DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
35150
+ const sorted = [...snapshots].sort(
35151
+ (a, b) => urgencyRank(a) - urgencyRank(b)
35152
+ );
35153
+ const headerTokens = approxTokenLen2(`${SECTION_HEADER}
35154
+ `);
35155
+ const sepTokens = approxTokenLen2("\n");
35156
+ let runningTokens = headerTokens;
35157
+ const kept = [];
35158
+ for (const snap of sorted) {
35159
+ const line = formatSnapshotLine(snap);
35160
+ const tokens = approxTokenLen2(line) + (kept.length > 0 ? sepTokens : 0);
35161
+ if (kept.length === 0) {
35162
+ kept.push(line);
35163
+ runningTokens += tokens;
35164
+ continue;
35165
+ }
35166
+ if (runningTokens + tokens > budget) break;
35167
+ kept.push(line);
35168
+ runningTokens += tokens;
35169
+ }
35170
+ return `${SECTION_HEADER}
35171
+ ${kept.join("\n")}`;
35172
+ }
35173
+ function generateProactiveStarter(snapshots) {
35174
+ if (snapshots.length === 0) return null;
35175
+ const stuck = snapshots.filter((s) => s.state_flags.includes("stuck"));
35176
+ if (stuck.length > 0) {
35177
+ const first = stuck[0];
35178
+ if (first === void 0) return null;
35179
+ const last = first.current_work_summary ? ` (last: ${first.current_work_summary})` : "";
35180
+ return {
35181
+ text: `Your ${first.agent_name} agent looks stuck${last}. Should I check its session state?`,
35182
+ trigger: "stuck_agent",
35183
+ triggered_agents_count: stuck.length
35184
+ };
35185
+ }
35186
+ const pending = snapshots.filter(
35187
+ (s) => s.state_flags.includes("has_pending_approvals")
35188
+ );
35189
+ if (pending.length > 0) {
35190
+ const names = pending.slice(0, 3).map((s) => s.agent_name).join(", ");
35191
+ return {
35192
+ text: `You have pending approvals across ${names}. Want to walk through them?`,
35193
+ trigger: "pending_approvals",
35194
+ triggered_agents_count: pending.length
35195
+ };
35196
+ }
35197
+ const findings = snapshots.filter(
35198
+ (s) => s.state_flags.includes("has_open_findings")
35199
+ );
35200
+ if (findings.length > 0) {
35201
+ return {
35202
+ text: `Sentinel has open findings on ${findings.length} ${findings.length === 1 ? "agent" : "agents"}. Want a summary?`,
35203
+ trigger: "open_findings",
35204
+ triggered_agents_count: findings.length
35205
+ };
35206
+ }
35207
+ return {
35208
+ text: "Your fortress is quiet. Anything you'd like to inspect?",
35209
+ trigger: "all_idle",
35210
+ triggered_agents_count: snapshots.length
35211
+ };
35212
+ }
35213
+
32612
35214
  // src/chat/operator-chat-service.ts
32613
35215
  var DEFAULT_CONCIERGE_MAX_TOKENS = 512;
32614
35216
  var DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
@@ -32616,7 +35218,8 @@ var DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
32616
35218
  var DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
32617
35219
  var DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
32618
35220
  var DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET = 2e3;
32619
- function approxTokenLen2(text) {
35221
+ var DEFAULT_CONCIERGE_AGENT_STATE_BUDGET = 400;
35222
+ function approxTokenLen3(text) {
32620
35223
  return Math.ceil(text.length / 4);
32621
35224
  }
32622
35225
  var SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
@@ -32663,6 +35266,17 @@ var OperatorChatService = class {
32663
35266
  contextFetchers;
32664
35267
  contextLlmAssist;
32665
35268
  dynamicContextBudget;
35269
+ agentRegistry;
35270
+ grammarLlmAssist;
35271
+ agentContextCache;
35272
+ agentStateBudget;
35273
+ /**
35274
+ * Per-thread guard so the proactive starter fires at most once per
35275
+ * fresh thread. Tracks the thread_id the starter was last offered
35276
+ * for; subsequent `getProactiveStarter()` calls within the same
35277
+ * thread return null instead of re-emitting.
35278
+ */
35279
+ starterOfferedForThreadId;
32666
35280
  /**
32667
35281
  * In-memory thread_id assigned to the active concierge session.
32668
35282
  * The first sendConcierge call after construction allocates a fresh
@@ -32701,6 +35315,16 @@ var OperatorChatService = class {
32701
35315
  this.contextLlmAssist = deps.conciergeContextLlmAssist;
32702
35316
  }
32703
35317
  this.dynamicContextBudget = deps.conciergeDynamicContextBudget !== void 0 && deps.conciergeDynamicContextBudget > 0 ? deps.conciergeDynamicContextBudget : DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET;
35318
+ if (deps.conciergeAgentRegistry) {
35319
+ this.agentRegistry = deps.conciergeAgentRegistry;
35320
+ }
35321
+ if (deps.conciergeGrammarLlmAssist) {
35322
+ this.grammarLlmAssist = deps.conciergeGrammarLlmAssist;
35323
+ }
35324
+ if (deps.conciergeAgentContextCache) {
35325
+ this.agentContextCache = deps.conciergeAgentContextCache;
35326
+ }
35327
+ this.agentStateBudget = deps.conciergeAgentStateBudget !== void 0 && deps.conciergeAgentStateBudget > 0 ? deps.conciergeAgentStateBudget : DEFAULT_CONCIERGE_AGENT_STATE_BUDGET;
32704
35328
  }
32705
35329
  // ── Concierge ─────────────────────────────────────────────────────────
32706
35330
  /**
@@ -32722,6 +35346,7 @@ var OperatorChatService = class {
32722
35346
  const nowMs = this.clock();
32723
35347
  if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
32724
35348
  this.activeMemoryThreadId = void 0;
35349
+ this.starterOfferedForThreadId = void 0;
32725
35350
  }
32726
35351
  const operatorMessage = {
32727
35352
  message_id: randomUUID(),
@@ -32759,6 +35384,12 @@ var OperatorChatService = class {
32759
35384
  await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
32760
35385
  });
32761
35386
  }
35387
+ const parsedGrammar = await this.runGrammarParse(filterResult.filtered);
35388
+ const agentSnapshots = this.agentContextCache ? this.agentContextCache.read() : [];
35389
+ const agentStateSection = this.agentContextCache ? formatCurrentAgentStateSection(agentSnapshots, {
35390
+ maxTokens: this.agentStateBudget
35391
+ }) : "";
35392
+ const renderedAgentCount = agentStateSection ? agentSnapshots.length : 0;
32762
35393
  const start = Date.now();
32763
35394
  let conciergeBody;
32764
35395
  let servedBy = "disabled";
@@ -32777,12 +35408,14 @@ var OperatorChatService = class {
32777
35408
  outcome = "substrate_disabled";
32778
35409
  } else {
32779
35410
  const dynamicResult = await this.runDynamicContextFold(
32780
- filterResult.filtered
35411
+ filterResult.filtered,
35412
+ parsedGrammar
32781
35413
  );
32782
35414
  dynamicCategoriesIncluded = dynamicResult.categoriesIncluded;
32783
35415
  const context = await this.assembleConciergeContext(
32784
35416
  priorTurns,
32785
- dynamicResult.section
35417
+ dynamicResult.section,
35418
+ agentStateSection
32786
35419
  );
32787
35420
  const response = await this.substrateSelector.invokeSummarize(
32788
35421
  "concierge",
@@ -32848,7 +35481,9 @@ var OperatorChatService = class {
32848
35481
  ...this.memory ? {
32849
35482
  prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
32850
35483
  } : {},
32851
- ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {}
35484
+ ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {},
35485
+ parsed_grammar: auditSafeSummary(parsedGrammar),
35486
+ ...this.agentContextCache !== void 0 ? { agent_context_snapshot_count: renderedAgentCount } : {}
32852
35487
  };
32853
35488
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
32854
35489
  return {
@@ -32959,6 +35594,7 @@ var OperatorChatService = class {
32959
35594
  if (!removed) return false;
32960
35595
  if (this.activeMemoryThreadId === threadId) {
32961
35596
  this.activeMemoryThreadId = void 0;
35597
+ this.starterOfferedForThreadId = void 0;
32962
35598
  }
32963
35599
  const payload = {
32964
35600
  version: "1.2",
@@ -32977,9 +35613,65 @@ var OperatorChatService = class {
32977
35613
  * Reset the active session memory thread. Subsequent sendConcierge
32978
35614
  * calls allocate a fresh thread_id. Surfaced for tests + future "new
32979
35615
  * conversation" affordance; not currently called by the dashboard.
35616
+ *
35617
+ * Tau-5: also clears the proactive-starter guard so the next
35618
+ * `getProactiveStarter()` call against the freshly-allocated thread
35619
+ * is eligible to fire.
32980
35620
  */
32981
35621
  resetConciergeMemoryThread() {
32982
35622
  this.activeMemoryThreadId = void 0;
35623
+ this.starterOfferedForThreadId = void 0;
35624
+ }
35625
+ /**
35626
+ * WP-V1.3-9 Tau-5: surface a proactive starter for the current
35627
+ * concierge session. Intended to be called by the dashboard UI when
35628
+ * the operator opens the chat surface, before any operator typing.
35629
+ *
35630
+ * Returns null when:
35631
+ * - No agent-context cache is wired (Tau-5 disabled).
35632
+ * - No concierge memory store is wired (no thread_id namespace).
35633
+ * - The cache snapshot has no signal (empty fortress).
35634
+ * - A starter has already been offered for the active thread (the
35635
+ * guard ensures one starter per fresh thread).
35636
+ *
35637
+ * Side effects:
35638
+ * - Allocates a fresh thread_id if none is active.
35639
+ * - Emits the `operator_concierge_proactive_suggestion_offered`
35640
+ * audit event with the trigger class + triggered_agents_count.
35641
+ * - Records the offered thread_id so the next call within the same
35642
+ * thread is a no-op.
35643
+ *
35644
+ * The returned starter's `text` is operator-visible copy; the
35645
+ * dashboard renders it as a system-message-style starter the
35646
+ * operator can accept (clicks/types follow-up) or dismiss (types a
35647
+ * new query).
35648
+ */
35649
+ getProactiveStarter() {
35650
+ if (!this.agentContextCache) return null;
35651
+ if (!this.memory) return null;
35652
+ const threadId = this.ensureActiveMemoryThread();
35653
+ if (this.starterOfferedForThreadId === threadId) return null;
35654
+ const snapshots = this.agentContextCache.read();
35655
+ const starter = generateProactiveStarter(snapshots);
35656
+ if (!starter) return null;
35657
+ const payload = {
35658
+ version: "1.2",
35659
+ event_id: makeEventId("conc-starter"),
35660
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
35661
+ identity_id: this.identityId,
35662
+ kind: "operator_concierge_proactive_suggestion_offered",
35663
+ surface: "concierge",
35664
+ thread_id: threadId,
35665
+ trigger: starter.trigger,
35666
+ triggered_agents_count: starter.triggered_agents_count
35667
+ };
35668
+ this.emit(
35669
+ OPERATOR_CHAT_OPS.CONCIERGE_PROACTIVE_SUGGESTION_OFFERED,
35670
+ payload,
35671
+ "success"
35672
+ );
35673
+ this.starterOfferedForThreadId = threadId;
35674
+ return starter;
32983
35675
  }
32984
35676
  ensureActiveMemoryThread() {
32985
35677
  if (!this.activeMemoryThreadId) {
@@ -33024,7 +35716,7 @@ var OperatorChatService = class {
33024
35716
  * if available; the v1.2 selector does not expose one, so structured
33025
35717
  * serialization is the canonical path for v1.3.
33026
35718
  */
33027
- async assembleConciergeContext(priorTurns = [], dynamicSection = "") {
35719
+ async assembleConciergeContext(priorTurns = [], dynamicSection = "", agentStateSection = "") {
33028
35720
  const ref = `## Sanctuary reference
33029
35721
  ${SANCTUARY_DOMAIN_REFERENCE}`;
33030
35722
  const priorSection = this.formatPriorTurnsSection(priorTurns);
@@ -33032,6 +35724,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
33032
35724
  return [
33033
35725
  ref,
33034
35726
  ...dynamicSection ? [dynamicSection] : [],
35727
+ ...agentStateSection ? [agentStateSection] : [],
33035
35728
  ...priorSection ? [priorSection] : [],
33036
35729
  "## Recent activity\n(no providers wired)",
33037
35730
  "## Wrapped agents\n(no providers wired)",
@@ -33046,6 +35739,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
33046
35739
  return [
33047
35740
  ref,
33048
35741
  ...dynamicSection ? [dynamicSection] : [],
35742
+ ...agentStateSection ? [agentStateSection] : [],
33049
35743
  ...priorSection ? [priorSection] : [],
33050
35744
  `## Recent activity
33051
35745
  ${activity}`,
@@ -33063,8 +35757,12 @@ ${inbox}`
33063
35757
  * proceeds with no fold. Returns the rendered section + the list of
33064
35758
  * categories whose data made it into the section (used for the
33065
35759
  * round-trip audit emission).
35760
+ *
35761
+ * Tau-4: receives the pre-parsed `ParsedQuery` and forwards it as the
35762
+ * `parsed` opt to `foldContext`, so fetchers see the structured
35763
+ * `FetcherHints` derived from it.
33066
35764
  */
33067
- async runDynamicContextFold(query) {
35765
+ async runDynamicContextFold(query, parsedGrammar) {
33068
35766
  if (!this.contextFetchers) {
33069
35767
  return { section: "", categoriesIncluded: [] };
33070
35768
  }
@@ -33073,10 +35771,24 @@ ${inbox}`
33073
35771
  ...this.contextLlmAssist ? { llmAssistClassify: this.contextLlmAssist } : {},
33074
35772
  onFetcherFailure: (category, error) => {
33075
35773
  this.emitContextFetcherFailed(category, classifyFetcherError(error));
33076
- }
35774
+ },
35775
+ parsed: parsedGrammar
33077
35776
  });
33078
35777
  return result;
33079
35778
  }
35779
+ /**
35780
+ * WP-V1.3-9 Tau-4: parse the (PII-filtered) operator query into a
35781
+ * `ParsedQuery`. Routes through the LLM-assist completion hook when
35782
+ * configured and the rule-based parse is below
35783
+ * `LLM_ASSIST_THRESHOLD`. Always returns a parse object (never
35784
+ * throws) so the audit emission can carry the result unconditionally.
35785
+ */
35786
+ async runGrammarParse(query) {
35787
+ return parseQueryWithLlmAssist(query, this.grammarLlmAssist, {
35788
+ ...this.agentRegistry !== void 0 ? { registry: this.agentRegistry } : {},
35789
+ eventClassEnum: CANONICAL_AUDIT_EVENT_CLASSES
35790
+ });
35791
+ }
33080
35792
  /**
33081
35793
  * Emit the WP-V1.3-9 Tau-3 fetcher-failure audit event. Pulled out
33082
35794
  * of the fold path so the dynamic-context handler stays readable.
@@ -33110,14 +35822,14 @@ ${inbox}`
33110
35822
  if (turns.length === 0) return "";
33111
35823
  const HEADER = "## Prior conversation";
33112
35824
  const lines = turns.map(formatPriorTurnLine);
33113
- const headerTokens = approxTokenLen2(`${HEADER}
35825
+ const headerTokens = approxTokenLen3(`${HEADER}
33114
35826
  `);
33115
- const sepTokens = approxTokenLen2("\n");
35827
+ const sepTokens = approxTokenLen3("\n");
33116
35828
  let runningTokens = headerTokens;
33117
35829
  let runningLines = [];
33118
35830
  for (let i = lines.length - 1; i >= 0; i--) {
33119
35831
  const line = lines[i];
33120
- const tokens = approxTokenLen2(line) + (runningLines.length > 0 ? sepTokens : 0);
35832
+ const tokens = approxTokenLen3(line) + (runningLines.length > 0 ? sepTokens : 0);
33121
35833
  if (runningTokens + tokens > this.historyTokenBudget) break;
33122
35834
  runningTokens += tokens;
33123
35835
  runningLines.push(line);
@@ -33164,7 +35876,7 @@ function hashOf(input) {
33164
35876
  init_encryption();
33165
35877
  init_encoding();
33166
35878
  var OPERATOR_CHAT_NAMESPACE = "_chat";
33167
- var HKDF_INFO2 = "operator-chat-store-v1";
35879
+ var HKDF_INFO3 = "operator-chat-store-v1";
33168
35880
  function chatStorageKey(surface, threadKey) {
33169
35881
  return `${surface}.${threadKey}`;
33170
35882
  }
@@ -33173,7 +35885,7 @@ var OperatorChatStore = class {
33173
35885
  encryptionKey;
33174
35886
  constructor(storage, masterKey) {
33175
35887
  this.storage = storage;
33176
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
35888
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
33177
35889
  }
33178
35890
  /**
33179
35891
  * Load a thread. Returns null if no record exists or if the on-disk
@@ -33257,7 +35969,7 @@ init_encryption();
33257
35969
  init_encoding();
33258
35970
  var CONCIERGE_MEMORY_NAMESPACE = "_chat";
33259
35971
  var CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
33260
- var HKDF_INFO3 = "concierge-memory-store-v1";
35972
+ var HKDF_INFO4 = "concierge-memory-store-v1";
33261
35973
  var DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
33262
35974
  var MAX_BUNDLE_BYTES3 = 4 * 1024 * 1024;
33263
35975
  var ConciergeMemoryStore = class {
@@ -33268,7 +35980,7 @@ var ConciergeMemoryStore = class {
33268
35980
  locks;
33269
35981
  constructor(opts) {
33270
35982
  this.storage = opts.storage;
33271
- this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO3);
35983
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO4);
33272
35984
  this.fortressId = opts.fortressId;
33273
35985
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
33274
35986
  this.locks = /* @__PURE__ */ new Map();
@@ -33394,7 +36106,7 @@ var ConciergeMemoryStore = class {
33394
36106
  );
33395
36107
  const summaries = [];
33396
36108
  for (const meta of entries) {
33397
- const threadId = stripKeyPrefix2(meta.key);
36109
+ const threadId = stripKeyPrefix3(meta.key);
33398
36110
  if (threadId === null) continue;
33399
36111
  const bundle = await this.loadBundle(threadId);
33400
36112
  if (!bundle || bundle.turns.length === 0) continue;
@@ -33447,7 +36159,7 @@ var ConciergeMemoryStore = class {
33447
36159
  );
33448
36160
  let pruned = 0;
33449
36161
  for (const meta of entries) {
33450
- const threadId = stripKeyPrefix2(meta.key);
36162
+ const threadId = stripKeyPrefix3(meta.key);
33451
36163
  if (threadId === null) continue;
33452
36164
  pruned += await this.withLock(threadId, async () => {
33453
36165
  const bundle = await this.loadBundle(threadId);
@@ -33531,7 +36243,7 @@ var ConciergeMemoryStore = class {
33531
36243
  function bundleKey(threadId) {
33532
36244
  return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
33533
36245
  }
33534
- function stripKeyPrefix2(key) {
36246
+ function stripKeyPrefix3(key) {
33535
36247
  if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
33536
36248
  return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
33537
36249
  }
@@ -33904,13 +36616,13 @@ init_encryption();
33904
36616
  init_encoding();
33905
36617
  var INTELLIGENCE_NAMESPACE = "_intelligence";
33906
36618
  var SUBSTRATE_CONFIG_KEY = "substrate-config";
33907
- var HKDF_INFO4 = "intelligence-substrate-config";
36619
+ var HKDF_INFO5 = "intelligence-substrate-config";
33908
36620
  var IntelligenceConfigStore = class {
33909
36621
  storage;
33910
36622
  encryptionKey;
33911
36623
  constructor(storage, masterKey) {
33912
36624
  this.storage = storage;
33913
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO4);
36625
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO5);
33914
36626
  }
33915
36627
  /**
33916
36628
  * Load the operator's substrate config from disk. Returns the config
@@ -37889,6 +40601,38 @@ ${err.message}
37889
40601
  if (dashboard) {
37890
40602
  dashboard.setApprovalAggregator(approvalAggregator);
37891
40603
  }
40604
+ const sentinelFindingStore = new SentinelFindingStore({
40605
+ storage,
40606
+ masterKey,
40607
+ fortressId: fortressIdForAggregator
40608
+ });
40609
+ const sentinelRegistry = new SentinelRegistry();
40610
+ for (const entry of PHI1_BASELINE_CATALOG) {
40611
+ sentinelRegistry.register(entry);
40612
+ }
40613
+ const sentinelDispatcher = new SentinelDispatcher({
40614
+ registry: sentinelRegistry,
40615
+ findingStore: sentinelFindingStore,
40616
+ auditLog,
40617
+ fortressId: fortressIdForAggregator,
40618
+ identityId: aggregatorIdentityId
40619
+ });
40620
+ try {
40621
+ const persistedSubscriptions = await loadSentinelSubscriptions(
40622
+ config.storage_path
40623
+ );
40624
+ for (const sentinelId of persistedSubscriptions) {
40625
+ try {
40626
+ await sentinelDispatcher.subscribeSentinel(sentinelId);
40627
+ } catch {
40628
+ }
40629
+ }
40630
+ } catch {
40631
+ }
40632
+ sentinelDispatcher.start();
40633
+ if (dashboard) {
40634
+ dashboard.setSentinelDispatcher(sentinelDispatcher);
40635
+ }
37892
40636
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
37893
40637
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
37894
40638
  config,