@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/cli.js CHANGED
@@ -5037,7 +5037,8 @@ var init_constants = __esm({
5037
5037
  RESERVED_EVENT_TYPE_PREFIXES = [
5038
5038
  "EXTENSION_",
5039
5039
  "cross_fortress_",
5040
- "multi_master_"
5040
+ "multi_master_",
5041
+ "cross_harness_approval_"
5041
5042
  ];
5042
5043
  RESERVED_EXTENSION_ENVELOPE_KEYS = [
5043
5044
  "cross_fortress_read_grant",
@@ -9229,9 +9230,9 @@ function fingerprintDID(did) {
9229
9230
  return `${raw.slice(0, 6)}\u2026${raw.slice(-6)}`;
9230
9231
  }
9231
9232
  function countInjectionsToday(audit) {
9232
- const startOfDay = /* @__PURE__ */ new Date();
9233
- startOfDay.setHours(0, 0, 0, 0);
9234
- const cutoff = startOfDay.getTime();
9233
+ const startOfDay2 = /* @__PURE__ */ new Date();
9234
+ startOfDay2.setHours(0, 0, 0, 0);
9235
+ const cutoff = startOfDay2.getTime();
9235
9236
  return audit.filter((e) => {
9236
9237
  const ts = new Date(e.timestamp).getTime();
9237
9238
  if (isNaN(ts) || ts < cutoff) return false;
@@ -9240,9 +9241,9 @@ function countInjectionsToday(audit) {
9240
9241
  }).length;
9241
9242
  }
9242
9243
  function countProofsToday(audit) {
9243
- const startOfDay = /* @__PURE__ */ new Date();
9244
- startOfDay.setHours(0, 0, 0, 0);
9245
- const cutoff = startOfDay.getTime();
9244
+ const startOfDay2 = /* @__PURE__ */ new Date();
9245
+ startOfDay2.setHours(0, 0, 0, 0);
9246
+ const cutoff = startOfDay2.getTime();
9246
9247
  return audit.filter((e) => {
9247
9248
  if (e.layer !== "l3") return false;
9248
9249
  if (!PROOF_CREATION_OPS.has(e.operation)) return false;
@@ -17211,6 +17212,24 @@ async function handleApprovalInboxRoute(deps, req, res) {
17211
17212
  await handleStream2(deps, res);
17212
17213
  return true;
17213
17214
  }
17215
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/revision`) {
17216
+ const revision = await deps.aggregator.getRevision();
17217
+ writeJSON4(res, 200, { ok: true, data: { revision } });
17218
+ return true;
17219
+ }
17220
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/sync`) {
17221
+ const sinceRaw = url.searchParams.get("since_revision");
17222
+ const sinceParsed = sinceRaw === null ? 0 : Number.parseInt(sinceRaw, 10);
17223
+ const sinceRevision = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : 0;
17224
+ const limit = parseLimit2(
17225
+ url.searchParams.get("limit"),
17226
+ APPROVAL_INBOX_DEFAULT_LIMIT,
17227
+ APPROVAL_INBOX_MAX_LIMIT
17228
+ );
17229
+ const delta = await deps.aggregator.getSync({ sinceRevision, limit });
17230
+ writeJSON4(res, 200, { ok: true, data: delta });
17231
+ return true;
17232
+ }
17214
17233
  if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/history`) {
17215
17234
  const limit = parseLimit2(
17216
17235
  url.searchParams.get("limit"),
@@ -17335,6 +17354,125 @@ var init_approval_aggregator_routes = __esm({
17335
17354
  APPROVAL_INBOX_MAX_LIMIT = 200;
17336
17355
  }
17337
17356
  });
17357
+
17358
+ // src/sentinel/sentinel-routes.ts
17359
+ function writeJSON5(res, status, payload) {
17360
+ res.writeHead(status, {
17361
+ "Content-Type": "application/json",
17362
+ "Cache-Control": "no-store"
17363
+ });
17364
+ res.end(JSON.stringify(payload));
17365
+ }
17366
+ function isSeverity(value) {
17367
+ return value === "info" || value === "warn" || value === "alert";
17368
+ }
17369
+ function parseLimit3(raw, defaultValue, max) {
17370
+ if (raw === null || raw === "") return defaultValue;
17371
+ const parsed = Number.parseInt(raw, 10);
17372
+ if (Number.isNaN(parsed) || parsed < 0) return defaultValue;
17373
+ return Math.min(parsed, max);
17374
+ }
17375
+ function matchSubscribeRoute(path) {
17376
+ const prefix = `${SENTINEL_API_PREFIX}/`;
17377
+ if (!path.startsWith(prefix)) return null;
17378
+ const rest = path.slice(prefix.length);
17379
+ if (!rest.endsWith("/subscribe")) return null;
17380
+ const sentinelId = rest.slice(0, rest.length - "/subscribe".length);
17381
+ if (sentinelId.length === 0) return null;
17382
+ return { sentinelId: decodeURIComponent(sentinelId) };
17383
+ }
17384
+ async function handleSentinelRoute(deps, req, res) {
17385
+ const host = req.headers.host || "localhost";
17386
+ const url = new URL(req.url ?? "/", `http://${host}`);
17387
+ const method = (req.method ?? "GET").toUpperCase();
17388
+ const path = url.pathname;
17389
+ if (path !== SENTINEL_API_PREFIX && !path.startsWith(`${SENTINEL_API_PREFIX}/`)) {
17390
+ return false;
17391
+ }
17392
+ const checkAuth = authMiddleware(deps.authConfig);
17393
+ if (!checkAuth(req, res, url)) return true;
17394
+ const dispatcher = deps.dispatcher;
17395
+ const registry = dispatcher.getRegistry();
17396
+ const findingStore = dispatcher.getFindingStore();
17397
+ try {
17398
+ if (method === "GET" && path === SENTINEL_API_PREFIX) {
17399
+ const catalog = registry.listCatalog();
17400
+ writeJSON5(res, 200, { ok: true, data: { catalog } });
17401
+ return true;
17402
+ }
17403
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/subscribed`) {
17404
+ const subscribed = registry.listSubscribed();
17405
+ writeJSON5(res, 200, { ok: true, data: { subscribed } });
17406
+ return true;
17407
+ }
17408
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/findings`) {
17409
+ const limit = parseLimit3(
17410
+ url.searchParams.get("limit"),
17411
+ FINDINGS_DEFAULT_LIMIT,
17412
+ FINDINGS_MAX_LIMIT
17413
+ );
17414
+ const since = url.searchParams.get("since") ?? void 0;
17415
+ const severityRaw = url.searchParams.get("severity") ?? void 0;
17416
+ const sentinelIdFilter = url.searchParams.get("sentinel_id") ?? void 0;
17417
+ const agentIdFilter = url.searchParams.get("agent_id") ?? void 0;
17418
+ const severity = severityRaw && isSeverity(severityRaw) ? severityRaw : void 0;
17419
+ const findings = await findingStore.listFindings({
17420
+ limit,
17421
+ ...since !== void 0 ? { since } : {},
17422
+ ...severity !== void 0 ? { severity } : {},
17423
+ ...sentinelIdFilter !== void 0 ? { sentinelId: sentinelIdFilter } : {},
17424
+ ...agentIdFilter !== void 0 ? { agentId: agentIdFilter } : {}
17425
+ });
17426
+ writeJSON5(res, 200, { ok: true, data: { findings } });
17427
+ return true;
17428
+ }
17429
+ const subscribeMatch = matchSubscribeRoute(path);
17430
+ if (subscribeMatch) {
17431
+ if (method === "POST") {
17432
+ try {
17433
+ await dispatcher.subscribeSentinel(subscribeMatch.sentinelId);
17434
+ writeJSON5(res, 200, {
17435
+ ok: true,
17436
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: true }
17437
+ });
17438
+ } catch (err) {
17439
+ const msg = err instanceof Error ? err.message : String(err);
17440
+ if (msg.startsWith("sentinel-registry: unknown sentinel")) {
17441
+ writeJSON5(res, 404, { ok: false, error: "not_found" });
17442
+ } else {
17443
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
17444
+ }
17445
+ }
17446
+ return true;
17447
+ }
17448
+ if (method === "DELETE") {
17449
+ const removed = await dispatcher.unsubscribeSentinel(
17450
+ subscribeMatch.sentinelId
17451
+ );
17452
+ writeJSON5(res, 200, {
17453
+ ok: true,
17454
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: false, removed }
17455
+ });
17456
+ return true;
17457
+ }
17458
+ }
17459
+ writeJSON5(res, 404, { ok: false, error: "not_found", path });
17460
+ return true;
17461
+ } catch (err) {
17462
+ const msg = err instanceof Error ? err.message : String(err);
17463
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
17464
+ return true;
17465
+ }
17466
+ }
17467
+ var SENTINEL_API_PREFIX, FINDINGS_DEFAULT_LIMIT, FINDINGS_MAX_LIMIT;
17468
+ var init_sentinel_routes = __esm({
17469
+ "src/sentinel/sentinel-routes.ts"() {
17470
+ init_auth_middleware();
17471
+ SENTINEL_API_PREFIX = "/api/sentinels";
17472
+ FINDINGS_DEFAULT_LIMIT = 100;
17473
+ FINDINGS_MAX_LIMIT = 500;
17474
+ }
17475
+ });
17338
17476
  function isDashboardViewRoute(method, path) {
17339
17477
  if (method !== "GET") return false;
17340
17478
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -17349,6 +17487,7 @@ var init_dashboard = __esm({
17349
17487
  init_system_prompt_generator();
17350
17488
  init_dispatch();
17351
17489
  init_approval_aggregator_routes();
17490
+ init_sentinel_routes();
17352
17491
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
17353
17492
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
17354
17493
  MAX_SESSIONS = 1e3;
@@ -17416,6 +17555,13 @@ var init_dashboard = __esm({
17416
17555
  * the operator-facing query / decision surface.
17417
17556
  */
17418
17557
  approvalAggregator = null;
17558
+ /**
17559
+ * v1.3 WP-V1.3-1 Phi-1 Sentinel dispatcher. Mounted additively at
17560
+ * `/api/sentinels/*` when set. Sentinel surface is read-only against
17561
+ * the audit log; subscribe/unsubscribe writes flow through the
17562
+ * dispatcher's audited paths.
17563
+ */
17564
+ sentinelDispatcher = null;
17419
17565
  constructor(config) {
17420
17566
  this.config = config;
17421
17567
  this.authToken = config.auth_token;
@@ -17475,6 +17621,14 @@ var init_dashboard = __esm({
17475
17621
  setApprovalAggregator(aggregator) {
17476
17622
  this.approvalAggregator = aggregator;
17477
17623
  }
17624
+ /**
17625
+ * v1.3 WP-V1.3-1 Phi-1: bind the Sentinel dispatcher. Once set,
17626
+ * requests to `/api/sentinels/*` route through `handleSentinelRoute`.
17627
+ * Pass `null` to detach (used by tests + during shutdown).
17628
+ */
17629
+ setSentinelDispatcher(dispatcher) {
17630
+ this.sentinelDispatcher = dispatcher;
17631
+ }
17478
17632
  /**
17479
17633
  * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17480
17634
  * before the legacy approval route table. Returns true when served.
@@ -17494,6 +17648,25 @@ var init_dashboard = __esm({
17494
17648
  res
17495
17649
  );
17496
17650
  }
17651
+ /**
17652
+ * v1.3 WP-V1.3-1 Phi-1 dispatch entry point. Routes `/api/sentinels/*`
17653
+ * requests through the sentinel router when a dispatcher has been
17654
+ * bound. Returns true when served.
17655
+ */
17656
+ async dispatchSentinel(req, res) {
17657
+ if (!this.sentinelDispatcher) return false;
17658
+ return handleSentinelRoute(
17659
+ {
17660
+ authConfig: {
17661
+ loopbackAutoAuth: this._autoAuthLocalhost,
17662
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17663
+ },
17664
+ dispatcher: this.sentinelDispatcher
17665
+ },
17666
+ req,
17667
+ res
17668
+ );
17669
+ }
17497
17670
  /**
17498
17671
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17499
17672
  * legacy route table. Returns true when the request was served by v1.1
@@ -17881,6 +18054,18 @@ var init_dashboard = __esm({
17881
18054
  });
17882
18055
  return;
17883
18056
  }
18057
+ if (this.sentinelDispatcher && url.pathname.startsWith(SENTINEL_API_PREFIX)) {
18058
+ this.dispatchSentinel(req, res).then((handled) => {
18059
+ if (handled) return;
18060
+ this.handleLegacyRequest(req, res, url, method);
18061
+ }).catch(() => {
18062
+ if (!res.headersSent) {
18063
+ res.writeHead(500, { "Content-Type": "application/json" });
18064
+ res.end(JSON.stringify({ error: "Internal server error" }));
18065
+ }
18066
+ });
18067
+ return;
18068
+ }
17884
18069
  if (this.v11Bindings) {
17885
18070
  this.dispatchV11(req, res, url, method).then((handled) => {
17886
18071
  if (handled) return;
@@ -20353,6 +20538,20 @@ var init_approval_aggregator = __esm({
20353
20538
  hydrated = false;
20354
20539
  /** Active SSE listeners. */
20355
20540
  listeners = /* @__PURE__ */ new Set();
20541
+ /**
20542
+ * Monotonic revision counter, bumped on every mutation (ingest of new
20543
+ * entry, resolve, expire, delete). Hydrated from max(last_modified_revision)
20544
+ * across persisted entries on first read; in-memory after that. v1.3
20545
+ * Upsilon-4.
20546
+ */
20547
+ currentRevision = 0;
20548
+ /**
20549
+ * Removal tombstones: aggregator_id -> revision at removal. Used by the
20550
+ * sync API to surface "removed" entries to mobile consumers between
20551
+ * polls. In-memory only; server restart clears tombstones (mobile
20552
+ * bootstraps via `list()` on reconnect). v1.3 Upsilon-4.
20553
+ */
20554
+ removedTombstones = /* @__PURE__ */ new Map();
20356
20555
  constructor(deps) {
20357
20556
  this.storage = deps.storage;
20358
20557
  this.encryptionKey = derivePurposeKey(
@@ -20387,6 +20586,113 @@ var init_approval_aggregator = __esm({
20387
20586
  this.listeners.add(listener);
20388
20587
  return () => this.listeners.delete(listener);
20389
20588
  }
20589
+ /**
20590
+ * Current aggregator revision. v1.3 Upsilon-4. Mobile companions
20591
+ * poll the lightweight `/revision` route to detect that something
20592
+ * changed before fetching a full sync delta.
20593
+ */
20594
+ async getRevision() {
20595
+ await this.hydrate();
20596
+ return this.currentRevision;
20597
+ }
20598
+ /**
20599
+ * Compute a delta since `sinceRevision`. v1.3 Upsilon-4. Mobile
20600
+ * clients poll this for cheap state-sync. Behavior:
20601
+ * - `added`: entries whose `created_at_revision > sinceRevision`.
20602
+ * - `changed`: entries that existed at `sinceRevision` but had a
20603
+ * status transition (resolve, expire) since.
20604
+ * - `removed`: aggregator_ids deleted after `sinceRevision`.
20605
+ * - `revision`: current aggregator revision; pass this back as
20606
+ * `sinceRevision` on the next call.
20607
+ *
20608
+ * `limit` caps the total count returned across all three lists,
20609
+ * prioritized as added -> changed -> removed (newer-state first).
20610
+ * When more changes exist than fit, the next call with the returned
20611
+ * revision will pick up the rest because each entry's
20612
+ * last_modified_revision is unchanged by truncation.
20613
+ */
20614
+ async getSync(opts) {
20615
+ await this.hydrate();
20616
+ await this.expireStale();
20617
+ const sinceRevision = opts?.sinceRevision ?? 0;
20618
+ const cap = Math.min(
20619
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20620
+ this.maxListLimit
20621
+ );
20622
+ const added = [];
20623
+ const changed = [];
20624
+ for (const entry of this.entries.values()) {
20625
+ const lastMod = entry.last_modified_revision ?? 0;
20626
+ if (lastMod <= sinceRevision) continue;
20627
+ const createdRev = entry.created_at_revision ?? 0;
20628
+ if (createdRev > sinceRevision) {
20629
+ added.push(entry);
20630
+ } else {
20631
+ changed.push(entry);
20632
+ }
20633
+ }
20634
+ const removed = [];
20635
+ for (const [id, rev] of this.removedTombstones) {
20636
+ if (rev > sinceRevision) removed.push(id);
20637
+ }
20638
+ added.sort(
20639
+ (a, b) => (a.last_modified_revision ?? 0) - (b.last_modified_revision ?? 0)
20640
+ );
20641
+ changed.sort(
20642
+ (a, b) => (a.last_modified_revision ?? 0) - (b.last_modified_revision ?? 0)
20643
+ );
20644
+ let remaining = cap;
20645
+ const addedOut = added.slice(0, Math.max(0, remaining));
20646
+ remaining -= addedOut.length;
20647
+ const changedOut = changed.slice(0, Math.max(0, remaining));
20648
+ remaining -= changedOut.length;
20649
+ const removedOut = removed.slice(0, Math.max(0, remaining));
20650
+ return {
20651
+ revision: this.currentRevision,
20652
+ added: addedOut,
20653
+ changed: changedOut,
20654
+ removed: removedOut
20655
+ };
20656
+ }
20657
+ /**
20658
+ * Delete an entry. Drops the in-memory record, the persisted bundle,
20659
+ * and the at-rest payload (if a payload store is wired). Records a
20660
+ * tombstone with the new revision so sync-API consumers see a
20661
+ * `removed` delta. Returns true when an entry was deleted, false on
20662
+ * unknown id. v1.3 Upsilon-4. Reserved for v1.4+ retention housekeeping;
20663
+ * Upsilon-4 ships the surface so mobile sync-API tests can exercise the
20664
+ * removal path.
20665
+ */
20666
+ async deleteEntry(aggregatorId) {
20667
+ await this.hydrate();
20668
+ const entry = this.entries.get(aggregatorId);
20669
+ if (!entry) return false;
20670
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20671
+ this.entries.delete(aggregatorId);
20672
+ this.dedupIndex.delete(dedupKey);
20673
+ this.fullPayloads.delete(aggregatorId);
20674
+ for (const [corr, id] of this.correlationIndex) {
20675
+ if (id === aggregatorId) this.correlationIndex.delete(corr);
20676
+ }
20677
+ try {
20678
+ await this.storage.delete(APPROVAL_AGGREGATOR_NAMESPACE, aggregatorId);
20679
+ } catch {
20680
+ }
20681
+ if (this.payloadStore) {
20682
+ try {
20683
+ await this.payloadStore.deletePayload(aggregatorId);
20684
+ } catch {
20685
+ }
20686
+ }
20687
+ const revision = this.nextRevision();
20688
+ this.removedTombstones.set(aggregatorId, revision);
20689
+ this.emit({ type: "removed", entry: { ...entry } });
20690
+ return true;
20691
+ }
20692
+ nextRevision() {
20693
+ this.currentRevision += 1;
20694
+ return this.currentRevision;
20695
+ }
20390
20696
  /**
20391
20697
  * Ingest a gate event. Returns the aggregator entry on first sight,
20392
20698
  * `null` when deduped. Resolution events update the existing record;
@@ -20597,6 +20903,7 @@ var init_approval_aggregator = __esm({
20597
20903
  entry.status = decision;
20598
20904
  entry.resolved_at = this.now().toISOString();
20599
20905
  entry.resolved_by = operatorId;
20906
+ entry.last_modified_revision = this.nextRevision();
20600
20907
  await this.persist(entry);
20601
20908
  this.auditLog.append(
20602
20909
  "l2",
@@ -20648,6 +20955,7 @@ var init_approval_aggregator = __esm({
20648
20955
  const expires = new Date(now.getTime() + this.pendingTtlMs);
20649
20956
  const hubInboxId = this.resolveHubInboxItemId(event);
20650
20957
  const enforcementChain = this.resolveEnforcementChain(event);
20958
+ const revision = this.nextRevision();
20651
20959
  const entry = {
20652
20960
  aggregator_id: id,
20653
20961
  source_harness: ctx.source_harness,
@@ -20659,6 +20967,8 @@ var init_approval_aggregator = __esm({
20659
20967
  status: "pending",
20660
20968
  created_at: now.toISOString(),
20661
20969
  expires_at: expires.toISOString(),
20970
+ created_at_revision: revision,
20971
+ last_modified_revision: revision,
20662
20972
  ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {},
20663
20973
  ...enforcementChain.length > 0 ? { enforcement_chain: enforcementChain } : {}
20664
20974
  };
@@ -20701,6 +21011,7 @@ var init_approval_aggregator = __esm({
20701
21011
  entry.status = status;
20702
21012
  entry.resolved_at = event.resolution.decided_at;
20703
21013
  entry.resolved_by = event.resolution.decided_by;
21014
+ entry.last_modified_revision = this.nextRevision();
20704
21015
  await this.persist(entry);
20705
21016
  this.auditLog.append(
20706
21017
  "l2",
@@ -20763,6 +21074,7 @@ var init_approval_aggregator = __esm({
20763
21074
  entry.status = "expired";
20764
21075
  entry.resolved_at = this.now().toISOString();
20765
21076
  entry.resolved_by = "system_ttl";
21077
+ entry.last_modified_revision = this.nextRevision();
20766
21078
  await this.persist(entry);
20767
21079
  this.auditLog.append(
20768
21080
  "l2",
@@ -20809,6 +21121,10 @@ var init_approval_aggregator = __esm({
20809
21121
  this.entries.set(entry.aggregator_id, entry);
20810
21122
  const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20811
21123
  this.dedupIndex.set(dedupKey, entry.aggregator_id);
21124
+ const lastMod = entry.last_modified_revision ?? 0;
21125
+ if (lastMod > this.currentRevision) {
21126
+ this.currentRevision = lastMod;
21127
+ }
20812
21128
  } catch {
20813
21129
  }
20814
21130
  }
@@ -21131,6 +21447,1725 @@ var init_aggregator_store = __esm({
21131
21447
  }
21132
21448
  });
21133
21449
 
21450
+ // src/sentinel/types.ts
21451
+ function isProxyCallAuditEntry(entry) {
21452
+ return entry.operation.startsWith(
21453
+ SENTINEL_OBSERVED_AUDIT_OPS.PROXY_CALL_PREFIX
21454
+ );
21455
+ }
21456
+ function proxyServerFromAuditEntry(entry) {
21457
+ if (!isProxyCallAuditEntry(entry)) return null;
21458
+ const details = entry.details;
21459
+ if (!details) return null;
21460
+ const server = details["server"];
21461
+ if (typeof server !== "string" || server.length === 0) return null;
21462
+ return server;
21463
+ }
21464
+ var SENTINEL_SUMMARY_MAX_CHARS, SENTINEL_AUDIT_OPS, SENTINEL_OBSERVED_AUDIT_OPS;
21465
+ var init_types3 = __esm({
21466
+ "src/sentinel/types.ts"() {
21467
+ SENTINEL_SUMMARY_MAX_CHARS = 240;
21468
+ SENTINEL_AUDIT_OPS = {
21469
+ SUBSCRIBED: "sentinel_subscribed",
21470
+ UNSUBSCRIBED: "sentinel_unsubscribed",
21471
+ FINDING_EMITTED: "sentinel_finding_emitted",
21472
+ EVALUATION_FAILED: "sentinel_evaluation_failed"
21473
+ };
21474
+ SENTINEL_OBSERVED_AUDIT_OPS = {
21475
+ /** Proxy router emits this on every outbound call (success or failure). */
21476
+ PROXY_CALL_PREFIX: "proxy_call:"
21477
+ };
21478
+ }
21479
+ });
21480
+
21481
+ // src/sentinel/sentinel-finding-store.ts
21482
+ function findingKey(findingId) {
21483
+ return `${SENTINEL_FINDING_KEY_PREFIX}${findingId}`;
21484
+ }
21485
+ function stripKeyPrefix2(key) {
21486
+ if (!key.startsWith(SENTINEL_FINDING_KEY_PREFIX)) return null;
21487
+ return key.slice(SENTINEL_FINDING_KEY_PREFIX.length);
21488
+ }
21489
+ function truncateSummary(summary) {
21490
+ if (summary.length <= SENTINEL_SUMMARY_MAX_CHARS) return summary;
21491
+ return `${summary.slice(0, SENTINEL_SUMMARY_MAX_CHARS - 3)}...`;
21492
+ }
21493
+ var SENTINEL_FINDING_NAMESPACE, SENTINEL_FINDING_KEY_PREFIX, HKDF_INFO2, DEFAULT_SENTINEL_FINDING_RETENTION_DAYS, MAX_FINDING_BYTES, SentinelFindingStore;
21494
+ var init_sentinel_finding_store = __esm({
21495
+ "src/sentinel/sentinel-finding-store.ts"() {
21496
+ init_encryption();
21497
+ init_key_derivation();
21498
+ init_encoding();
21499
+ init_types3();
21500
+ SENTINEL_FINDING_NAMESPACE = "_sentinel_findings";
21501
+ SENTINEL_FINDING_KEY_PREFIX = "finding.";
21502
+ HKDF_INFO2 = "l2-sentinel-finding-v1";
21503
+ DEFAULT_SENTINEL_FINDING_RETENTION_DAYS = 30;
21504
+ MAX_FINDING_BYTES = 256 * 1024;
21505
+ SentinelFindingStore = class {
21506
+ storage;
21507
+ encryptionKey;
21508
+ fortressId;
21509
+ retentionDays;
21510
+ now;
21511
+ constructor(opts) {
21512
+ this.storage = opts.storage;
21513
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
21514
+ this.fortressId = opts.fortressId;
21515
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_SENTINEL_FINDING_RETENTION_DAYS;
21516
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
21517
+ }
21518
+ /**
21519
+ * Persist a finding. Truncates the operator-visible summary to
21520
+ * SENTINEL_SUMMARY_MAX_CHARS so the dashboard render stays bounded.
21521
+ * Returns the retention deadline so callers can audit it.
21522
+ */
21523
+ async saveFinding(finding) {
21524
+ const truncated = {
21525
+ ...finding,
21526
+ fortress_id: this.fortressId,
21527
+ summary: truncateSummary(finding.summary)
21528
+ };
21529
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
21530
+ const retentionUntil = new Date(this.now().getTime() + retentionMs);
21531
+ const persisted = {
21532
+ version: 1,
21533
+ finding: truncated,
21534
+ retention_until: retentionUntil.toISOString()
21535
+ };
21536
+ const aad = stringToBytes(finding.finding_id);
21537
+ const plaintext = stringToBytes(JSON.stringify(persisted));
21538
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
21539
+ await this.storage.write(
21540
+ SENTINEL_FINDING_NAMESPACE,
21541
+ findingKey(finding.finding_id),
21542
+ stringToBytes(JSON.stringify(envelope))
21543
+ );
21544
+ return persisted.retention_until;
21545
+ }
21546
+ /** Load a single finding by id, or null when absent / corrupted. */
21547
+ async loadFinding(findingId) {
21548
+ let raw;
21549
+ try {
21550
+ raw = await this.storage.read(
21551
+ SENTINEL_FINDING_NAMESPACE,
21552
+ findingKey(findingId)
21553
+ );
21554
+ } catch {
21555
+ return null;
21556
+ }
21557
+ if (!raw) return null;
21558
+ if (raw.length > MAX_FINDING_BYTES) return null;
21559
+ return this.decode(findingId, raw);
21560
+ }
21561
+ /**
21562
+ * List findings, newest first. Optional filters: since (ISO 8601),
21563
+ * severity, sentinel_id, agent_id, limit. Default limit 100.
21564
+ */
21565
+ async listFindings(opts) {
21566
+ const metas = await this.storage.list(
21567
+ SENTINEL_FINDING_NAMESPACE,
21568
+ SENTINEL_FINDING_KEY_PREFIX
21569
+ );
21570
+ const findings = [];
21571
+ for (const meta of metas) {
21572
+ const id = stripKeyPrefix2(meta.key);
21573
+ if (id === null) continue;
21574
+ const raw = await this.storage.read(
21575
+ SENTINEL_FINDING_NAMESPACE,
21576
+ meta.key
21577
+ );
21578
+ if (!raw) continue;
21579
+ if (raw.length > MAX_FINDING_BYTES) continue;
21580
+ const finding = await this.decode(id, raw);
21581
+ if (!finding) continue;
21582
+ if (opts?.since && finding.observed_at < opts.since) continue;
21583
+ if (opts?.severity && finding.severity !== opts.severity) continue;
21584
+ if (opts?.sentinelId && finding.sentinel_id !== opts.sentinelId) continue;
21585
+ if (opts?.agentId && finding.agent_id !== opts.agentId) continue;
21586
+ findings.push(finding);
21587
+ }
21588
+ findings.sort((a, b) => a.observed_at < b.observed_at ? 1 : -1);
21589
+ const limit = opts?.limit ?? 100;
21590
+ return findings.slice(0, limit);
21591
+ }
21592
+ /**
21593
+ * Drop expired findings. Returns the count removed.
21594
+ */
21595
+ async pruneExpired(now) {
21596
+ const cutoff = (now ?? this.now()).toISOString();
21597
+ const metas = await this.storage.list(
21598
+ SENTINEL_FINDING_NAMESPACE,
21599
+ SENTINEL_FINDING_KEY_PREFIX
21600
+ );
21601
+ let pruned = 0;
21602
+ for (const meta of metas) {
21603
+ const id = stripKeyPrefix2(meta.key);
21604
+ if (id === null) continue;
21605
+ const raw = await this.storage.read(
21606
+ SENTINEL_FINDING_NAMESPACE,
21607
+ meta.key
21608
+ );
21609
+ if (!raw) continue;
21610
+ try {
21611
+ const aad = stringToBytes(id);
21612
+ const envelope = JSON.parse(bytesToString(raw));
21613
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21614
+ const persisted = JSON.parse(
21615
+ bytesToString(plaintext)
21616
+ );
21617
+ if (persisted.retention_until <= cutoff) {
21618
+ await this.storage.delete(SENTINEL_FINDING_NAMESPACE, meta.key);
21619
+ pruned += 1;
21620
+ }
21621
+ } catch {
21622
+ }
21623
+ }
21624
+ return { pruned };
21625
+ }
21626
+ async decode(findingId, raw) {
21627
+ try {
21628
+ const aad = stringToBytes(findingId);
21629
+ const envelope = JSON.parse(bytesToString(raw));
21630
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21631
+ const persisted = JSON.parse(
21632
+ bytesToString(plaintext)
21633
+ );
21634
+ if (persisted.version !== 1) return null;
21635
+ if (persisted.finding.finding_id !== findingId) return null;
21636
+ if (persisted.finding.fortress_id !== this.fortressId) return null;
21637
+ return persisted.finding;
21638
+ } catch {
21639
+ return null;
21640
+ }
21641
+ }
21642
+ };
21643
+ }
21644
+ });
21645
+
21646
+ // src/sentinel/sentinel-registry.ts
21647
+ var SentinelRegistry;
21648
+ var init_sentinel_registry = __esm({
21649
+ "src/sentinel/sentinel-registry.ts"() {
21650
+ SentinelRegistry = class {
21651
+ catalog = /* @__PURE__ */ new Map();
21652
+ subscribed = /* @__PURE__ */ new Map();
21653
+ register(entry) {
21654
+ if (this.catalog.has(entry.sentinelId)) {
21655
+ throw new Error(
21656
+ `sentinel-registry: ${entry.sentinelId} already registered`
21657
+ );
21658
+ }
21659
+ this.catalog.set(entry.sentinelId, entry);
21660
+ }
21661
+ /**
21662
+ * Available sentinels (catalog view). Operator UI lists this so the
21663
+ * operator can pick what to subscribe to.
21664
+ */
21665
+ listCatalog() {
21666
+ return [...this.catalog.values()].map((entry) => ({
21667
+ sentinelId: entry.sentinelId,
21668
+ description: entry.description
21669
+ }));
21670
+ }
21671
+ /** Currently subscribed sentinel ids. */
21672
+ listSubscribed() {
21673
+ return [...this.subscribed.keys()];
21674
+ }
21675
+ /** Has the fortress opted into this sentinel? */
21676
+ isSubscribed(sentinelId) {
21677
+ return this.subscribed.has(sentinelId);
21678
+ }
21679
+ /**
21680
+ * Subscribe a sentinel to a fortress context. Idempotent: a second
21681
+ * subscribe call on an already-subscribed sentinel returns the
21682
+ * existing instance without re-running `subscribe()`.
21683
+ */
21684
+ async subscribe(sentinelId, context) {
21685
+ const existing = this.subscribed.get(sentinelId);
21686
+ if (existing) return existing;
21687
+ const entry = this.catalog.get(sentinelId);
21688
+ if (!entry) {
21689
+ throw new Error(`sentinel-registry: unknown sentinel ${sentinelId}`);
21690
+ }
21691
+ const instance = entry.factory();
21692
+ await instance.subscribe(context);
21693
+ this.subscribed.set(sentinelId, instance);
21694
+ return instance;
21695
+ }
21696
+ /**
21697
+ * Unsubscribe. Idempotent: unsubscribing an unsubscribed sentinel
21698
+ * returns false without throwing. Returns true when an active
21699
+ * subscription was torn down.
21700
+ */
21701
+ async unsubscribe(sentinelId) {
21702
+ const instance = this.subscribed.get(sentinelId);
21703
+ if (!instance) return false;
21704
+ try {
21705
+ await instance.unsubscribe();
21706
+ } finally {
21707
+ this.subscribed.delete(sentinelId);
21708
+ }
21709
+ return true;
21710
+ }
21711
+ /**
21712
+ * Snapshot of subscribed sentinels for the dispatcher's tick path.
21713
+ * Returned as an array so the dispatcher can iterate without holding
21714
+ * the map under modification.
21715
+ */
21716
+ snapshotSubscribed() {
21717
+ return [...this.subscribed.entries()].map(([sentinelId, sentinel]) => ({
21718
+ sentinelId,
21719
+ sentinel
21720
+ }));
21721
+ }
21722
+ /**
21723
+ * Tear down every subscription. Called by the dispatcher on
21724
+ * fortress-shutdown. Best-effort: a failing unsubscribe does not
21725
+ * abort the rest.
21726
+ */
21727
+ async unsubscribeAll() {
21728
+ const ids = [...this.subscribed.keys()];
21729
+ for (const id of ids) {
21730
+ try {
21731
+ await this.unsubscribe(id);
21732
+ } catch {
21733
+ }
21734
+ }
21735
+ }
21736
+ };
21737
+ }
21738
+ });
21739
+ var DEFAULT_TICK_INTERVAL_MS, SentinelDispatcher;
21740
+ var init_sentinel_dispatcher = __esm({
21741
+ "src/sentinel/sentinel-dispatcher.ts"() {
21742
+ init_types3();
21743
+ DEFAULT_TICK_INTERVAL_MS = 6e4;
21744
+ SentinelDispatcher = class {
21745
+ registry;
21746
+ findingStore;
21747
+ auditLog;
21748
+ fortressId;
21749
+ identityId;
21750
+ now;
21751
+ tickIntervalMs;
21752
+ listeners = /* @__PURE__ */ new Set();
21753
+ tickTimer = null;
21754
+ tickInFlight = false;
21755
+ constructor(deps) {
21756
+ this.registry = deps.registry;
21757
+ this.findingStore = deps.findingStore;
21758
+ this.auditLog = deps.auditLog;
21759
+ this.fortressId = deps.fortressId;
21760
+ this.identityId = deps.identityId;
21761
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
21762
+ this.tickIntervalMs = deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
21763
+ }
21764
+ /** Read-only view of the registry. Convenience for route handlers. */
21765
+ getRegistry() {
21766
+ return this.registry;
21767
+ }
21768
+ /** Read-only view of the finding store. Convenience for route handlers. */
21769
+ getFindingStore() {
21770
+ return this.findingStore;
21771
+ }
21772
+ /**
21773
+ * Subscribe an in-process listener. Returns an unsubscribe fn.
21774
+ */
21775
+ onEvent(listener) {
21776
+ this.listeners.add(listener);
21777
+ return () => this.listeners.delete(listener);
21778
+ }
21779
+ /**
21780
+ * Subscribe a sentinel to this fortress + emit the
21781
+ * `sentinel_subscribed` audit event. Wraps `registry.subscribe()` so
21782
+ * the audit emission lives at the dispatcher boundary (the
21783
+ * fortress-aware site).
21784
+ */
21785
+ async subscribeSentinel(sentinelId, contextOverrides) {
21786
+ const context = {
21787
+ fortressId: this.fortressId,
21788
+ auditLog: this.auditLog,
21789
+ now: this.now,
21790
+ ...contextOverrides ?? {}
21791
+ };
21792
+ const sentinel = await this.registry.subscribe(sentinelId, context);
21793
+ this.auditLog.append(
21794
+ "l2",
21795
+ SENTINEL_AUDIT_OPS.SUBSCRIBED,
21796
+ this.identityId,
21797
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
21798
+ );
21799
+ return sentinel;
21800
+ }
21801
+ /**
21802
+ * Unsubscribe + emit `sentinel_unsubscribed`. Returns true when an
21803
+ * active subscription was torn down. Audit fires only on successful
21804
+ * removal.
21805
+ */
21806
+ async unsubscribeSentinel(sentinelId) {
21807
+ const removed = await this.registry.unsubscribe(sentinelId);
21808
+ if (removed) {
21809
+ this.auditLog.append(
21810
+ "l2",
21811
+ SENTINEL_AUDIT_OPS.UNSUBSCRIBED,
21812
+ this.identityId,
21813
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
21814
+ );
21815
+ }
21816
+ return removed;
21817
+ }
21818
+ /**
21819
+ * Run one evaluation pass over every subscribed sentinel. Used by
21820
+ * the auto-tick AND by tests that want a synchronous evaluation
21821
+ * gate. Returns the findings produced this tick (already persisted
21822
+ * + audit-logged + emitted).
21823
+ */
21824
+ async tick() {
21825
+ if (this.tickInFlight) return [];
21826
+ this.tickInFlight = true;
21827
+ try {
21828
+ const subscribed = this.registry.snapshotSubscribed();
21829
+ const findings = [];
21830
+ for (const { sentinelId, sentinel } of subscribed) {
21831
+ try {
21832
+ const tickFindings = await sentinel.evaluate();
21833
+ for (const finding of tickFindings) {
21834
+ const stamped = await this.routeFinding(sentinelId, finding);
21835
+ findings.push(stamped);
21836
+ }
21837
+ } catch (err) {
21838
+ const errorMessage = err instanceof Error ? err.message : String(err);
21839
+ const observedAt = this.now().toISOString();
21840
+ this.auditLog.append(
21841
+ "l2",
21842
+ SENTINEL_AUDIT_OPS.EVALUATION_FAILED,
21843
+ this.identityId,
21844
+ {
21845
+ sentinel_id: sentinelId,
21846
+ fortress_id: this.fortressId,
21847
+ error_message: errorMessage
21848
+ },
21849
+ "failure"
21850
+ );
21851
+ this.emit({
21852
+ type: "evaluation_failed",
21853
+ sentinel_id: sentinelId,
21854
+ error_message: errorMessage,
21855
+ observed_at: observedAt
21856
+ });
21857
+ }
21858
+ }
21859
+ return findings;
21860
+ } finally {
21861
+ this.tickInFlight = false;
21862
+ }
21863
+ }
21864
+ /**
21865
+ * Start the auto-tick loop. No-op when tickIntervalMs is 0 or when
21866
+ * already started. Tests typically leave auto-tick off and call
21867
+ * `tick()` directly.
21868
+ */
21869
+ start() {
21870
+ if (this.tickTimer !== null) return;
21871
+ if (this.tickIntervalMs <= 0) return;
21872
+ this.tickTimer = setInterval(() => {
21873
+ void this.tick();
21874
+ }, this.tickIntervalMs);
21875
+ if (typeof this.tickTimer.unref === "function") {
21876
+ this.tickTimer.unref();
21877
+ }
21878
+ }
21879
+ /** Stop the auto-tick loop. Idempotent. */
21880
+ stop() {
21881
+ if (this.tickTimer === null) return;
21882
+ clearInterval(this.tickTimer);
21883
+ this.tickTimer = null;
21884
+ }
21885
+ /**
21886
+ * Tear down every subscription + stop the tick loop. Called on
21887
+ * fortress shutdown.
21888
+ */
21889
+ async dispose() {
21890
+ this.stop();
21891
+ await this.registry.unsubscribeAll();
21892
+ this.listeners.clear();
21893
+ }
21894
+ async routeFinding(sentinelId, raw) {
21895
+ const stamped = {
21896
+ ...raw,
21897
+ finding_id: raw.finding_id || randomUUID(),
21898
+ sentinel_id: sentinelId,
21899
+ fortress_id: this.fortressId,
21900
+ observed_at: raw.observed_at || this.now().toISOString()
21901
+ };
21902
+ await this.findingStore.saveFinding(stamped);
21903
+ this.auditLog.append(
21904
+ "l2",
21905
+ SENTINEL_AUDIT_OPS.FINDING_EMITTED,
21906
+ this.identityId,
21907
+ {
21908
+ sentinel_id: sentinelId,
21909
+ finding_id: stamped.finding_id,
21910
+ severity: stamped.severity,
21911
+ ...stamped.agent_id !== void 0 ? { agent_id: stamped.agent_id } : {},
21912
+ evidence_audit_ids: stamped.evidence_audit_ids,
21913
+ fortress_id: this.fortressId
21914
+ }
21915
+ );
21916
+ this.emit({ type: "finding", finding: stamped });
21917
+ return stamped;
21918
+ }
21919
+ emit(event) {
21920
+ for (const listener of this.listeners) {
21921
+ try {
21922
+ listener(event);
21923
+ } catch {
21924
+ }
21925
+ }
21926
+ }
21927
+ };
21928
+ }
21929
+ });
21930
+
21931
+ // src/sentinel/sentinel.ts
21932
+ var Sentinel;
21933
+ var init_sentinel = __esm({
21934
+ "src/sentinel/sentinel.ts"() {
21935
+ Sentinel = class {
21936
+ /**
21937
+ * Bind the sentinel to a fortress context. Called once on
21938
+ * subscribe. Default implementation stores the context on `this`;
21939
+ * sentinels that need additional setup (e.g. priming a baseline
21940
+ * cache) override.
21941
+ */
21942
+ async subscribe(context) {
21943
+ this.context = context;
21944
+ }
21945
+ /**
21946
+ * Tear down. Default implementation clears the context; subclasses
21947
+ * that hold timers or external handles override.
21948
+ */
21949
+ async unsubscribe() {
21950
+ this.context = void 0;
21951
+ }
21952
+ context;
21953
+ /** Internal helper: assert subscribed before evaluation. */
21954
+ requireContext() {
21955
+ if (!this.context) {
21956
+ throw new Error(
21957
+ `sentinel ${this.sentinelId}: evaluate() called before subscribe()`
21958
+ );
21959
+ }
21960
+ return this.context;
21961
+ }
21962
+ };
21963
+ }
21964
+ });
21965
+
21966
+ // src/sentinel/sentinels/egress-volume-watcher.ts
21967
+ var EGRESS_VOLUME_SENTINEL_ID, WARN_SIGMA, ALERT_SIGMA, BASELINE_WINDOWS, QUERY_LIMIT, EgressVolumeWatcher;
21968
+ var init_egress_volume_watcher = __esm({
21969
+ "src/sentinel/sentinels/egress-volume-watcher.ts"() {
21970
+ init_sentinel();
21971
+ init_types3();
21972
+ EGRESS_VOLUME_SENTINEL_ID = "egress-volume";
21973
+ WARN_SIGMA = 3;
21974
+ ALERT_SIGMA = 6;
21975
+ BASELINE_WINDOWS = 7;
21976
+ QUERY_LIMIT = 1e4;
21977
+ EgressVolumeWatcher = class extends Sentinel {
21978
+ sentinelId = EGRESS_VOLUME_SENTINEL_ID;
21979
+ 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.";
21980
+ /** Servers we have already produced an `info` baseline-established finding for. */
21981
+ baselineEstablished = /* @__PURE__ */ new Set();
21982
+ async evaluate() {
21983
+ const ctx = this.requireContext();
21984
+ const now = ctx.now();
21985
+ const windowMs = 24 * 60 * 60 * 1e3;
21986
+ const windowSpanMs = (BASELINE_WINDOWS + 1) * windowMs;
21987
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21988
+ const queryResult = await ctx.auditLog.query({
21989
+ since: sinceIso,
21990
+ layer: "l2",
21991
+ limit: QUERY_LIMIT
21992
+ });
21993
+ const entries = queryResult.entries.filter(isProxyCallAuditEntry);
21994
+ const byServer = /* @__PURE__ */ new Map();
21995
+ for (const entry of entries) {
21996
+ const server = proxyServerFromAuditEntry(entry);
21997
+ if (server === null) continue;
21998
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
21999
+ if (auditAge < 0) continue;
22000
+ const windowIdx = Math.floor(auditAge / windowMs);
22001
+ if (windowIdx > BASELINE_WINDOWS) continue;
22002
+ let snapshot = byServer.get(server);
22003
+ if (!snapshot) {
22004
+ snapshot = { windows: [] };
22005
+ for (let i = 0; i <= BASELINE_WINDOWS; i += 1) {
22006
+ snapshot.windows.push({ count: 0, evidence_audit_ids: [] });
22007
+ }
22008
+ byServer.set(server, snapshot);
22009
+ }
22010
+ const bucket = snapshot.windows[windowIdx];
22011
+ bucket.count += 1;
22012
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22013
+ bucket.evidence_audit_ids.push(`${entry.timestamp}:${entry.operation}`);
22014
+ }
22015
+ }
22016
+ const findings = [];
22017
+ for (const [server, snapshot] of byServer.entries()) {
22018
+ const finding = this.evaluateServer(server, snapshot, now);
22019
+ if (finding) findings.push(finding);
22020
+ }
22021
+ return findings;
22022
+ }
22023
+ /** Reset baseline-established memoization. Tests use this between runs. */
22024
+ resetBaselineMemo() {
22025
+ this.baselineEstablished.clear();
22026
+ }
22027
+ evaluateServer(server, snapshot, now) {
22028
+ const currentWindow = snapshot.windows[0];
22029
+ const baselineWindows = snapshot.windows.slice(1);
22030
+ const populatedBaselineWindows = baselineWindows.filter((w) => w.count > 0).length;
22031
+ if (populatedBaselineWindows < BASELINE_WINDOWS) {
22032
+ if (this.baselineEstablished.has(server)) return null;
22033
+ if (populatedBaselineWindows === 0 && currentWindow.count === 0) {
22034
+ return null;
22035
+ }
22036
+ return null;
22037
+ }
22038
+ const baselineCounts = baselineWindows.map((w) => w.count);
22039
+ const mean = baselineCounts.reduce((sum, c) => sum + c, 0) / baselineCounts.length;
22040
+ const variance = baselineCounts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / baselineCounts.length;
22041
+ const stddev = Math.sqrt(variance);
22042
+ const wasEstablished = this.baselineEstablished.has(server);
22043
+ this.baselineEstablished.add(server);
22044
+ if (!wasEstablished) {
22045
+ return {
22046
+ finding_id: "",
22047
+ sentinel_id: this.sentinelId,
22048
+ severity: "info",
22049
+ summary: `egress-volume baseline established for ${server}: mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS} prior days).`,
22050
+ details: {
22051
+ server,
22052
+ baseline_mean: mean,
22053
+ baseline_stddev: stddev,
22054
+ baseline_windows: baselineCounts,
22055
+ current_count: currentWindow.count
22056
+ },
22057
+ observed_at: now.toISOString(),
22058
+ evidence_audit_ids: [],
22059
+ fortress_id: ""
22060
+ };
22061
+ }
22062
+ const warnThreshold = mean + WARN_SIGMA * stddev;
22063
+ const alertThreshold = mean + ALERT_SIGMA * stddev;
22064
+ if (currentWindow.count > alertThreshold) {
22065
+ return this.buildAnomalyFinding(
22066
+ server,
22067
+ snapshot,
22068
+ mean,
22069
+ stddev,
22070
+ now,
22071
+ "alert",
22072
+ ALERT_SIGMA
22073
+ );
22074
+ }
22075
+ if (currentWindow.count > warnThreshold) {
22076
+ return this.buildAnomalyFinding(
22077
+ server,
22078
+ snapshot,
22079
+ mean,
22080
+ stddev,
22081
+ now,
22082
+ "warn",
22083
+ WARN_SIGMA
22084
+ );
22085
+ }
22086
+ return null;
22087
+ }
22088
+ buildAnomalyFinding(server, snapshot, mean, stddev, now, severity, sigma) {
22089
+ const currentWindow = snapshot.windows[0];
22090
+ const ratio = mean === 0 ? Infinity : currentWindow.count / mean;
22091
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22092
+ const summary = `${server} egress is ${ratioStr}: ${currentWindow.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
22093
+ return {
22094
+ finding_id: "",
22095
+ sentinel_id: this.sentinelId,
22096
+ severity,
22097
+ summary,
22098
+ details: {
22099
+ server,
22100
+ current_count: currentWindow.count,
22101
+ baseline_mean: mean,
22102
+ baseline_stddev: stddev,
22103
+ sigma_threshold: sigma,
22104
+ ratio
22105
+ },
22106
+ observed_at: now.toISOString(),
22107
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
22108
+ fortress_id: ""
22109
+ };
22110
+ }
22111
+ };
22112
+ }
22113
+ });
22114
+
22115
+ // src/sentinel/sentinels/cross-agent-chatter-watcher.ts
22116
+ function pairKey(sender, recipient) {
22117
+ return `${sender}|${recipient}`;
22118
+ }
22119
+ function pairFromKey(key) {
22120
+ const idx = key.indexOf("|");
22121
+ return { sender: key.slice(0, idx), recipient: key.slice(idx + 1) };
22122
+ }
22123
+ function computeNewPartners(byPair) {
22124
+ const currentBySource = /* @__PURE__ */ new Map();
22125
+ const priorBySource = /* @__PURE__ */ new Map();
22126
+ for (const [key, snap] of byPair.entries()) {
22127
+ const { sender, recipient } = pairFromKey(key);
22128
+ if (snap.windows[0] && snap.windows[0].count > 0) {
22129
+ let recipMap = currentBySource.get(sender);
22130
+ if (!recipMap) {
22131
+ recipMap = /* @__PURE__ */ new Map();
22132
+ currentBySource.set(sender, recipMap);
22133
+ }
22134
+ recipMap.set(recipient, snap.windows[0].evidence_audit_ids);
22135
+ }
22136
+ const priorTouched = snap.windows.slice(1).some((w) => w.count > 0);
22137
+ if (priorTouched) {
22138
+ let set = priorBySource.get(sender);
22139
+ if (!set) {
22140
+ set = /* @__PURE__ */ new Set();
22141
+ priorBySource.set(sender, set);
22142
+ }
22143
+ set.add(recipient);
22144
+ }
22145
+ }
22146
+ const out = /* @__PURE__ */ new Map();
22147
+ for (const [sender, recipMap] of currentBySource.entries()) {
22148
+ const prior = priorBySource.get(sender) ?? /* @__PURE__ */ new Set();
22149
+ if (prior.size === 0) {
22150
+ continue;
22151
+ }
22152
+ const newPartners = [];
22153
+ const evidence = [];
22154
+ for (const [recipient, recipEvidence] of recipMap.entries()) {
22155
+ if (!prior.has(recipient)) {
22156
+ newPartners.push(recipient);
22157
+ for (const id of recipEvidence) {
22158
+ if (evidence.length < 50) evidence.push(id);
22159
+ }
22160
+ }
22161
+ }
22162
+ if (newPartners.length === 0) continue;
22163
+ newPartners.sort();
22164
+ out.set(sender, {
22165
+ partners: newPartners,
22166
+ priorPartners: [...prior].sort(),
22167
+ evidenceAuditIds: evidence
22168
+ });
22169
+ }
22170
+ return out;
22171
+ }
22172
+ function extractInterAgentEvents(entries) {
22173
+ const out = [];
22174
+ for (const entry of entries) {
22175
+ const op = entry.operation;
22176
+ if (op === HANDOFF_OP) {
22177
+ const details = entry.details;
22178
+ const sender = optionalString(details, "sender_agent_id");
22179
+ const recipient = optionalString(details, "recipient_agent_id");
22180
+ if (!sender || !recipient || sender === recipient) continue;
22181
+ out.push({
22182
+ sender,
22183
+ recipient,
22184
+ timestampMs: Date.parse(entry.timestamp),
22185
+ auditId: `${entry.timestamp}:${entry.operation}`
22186
+ });
22187
+ continue;
22188
+ }
22189
+ if (CROSS_HARNESS_OPS.has(op)) {
22190
+ const details = entry.details;
22191
+ const sender = optionalString(details, "source_harness") ?? optionalString(details, "source_agent_id");
22192
+ if (!sender) continue;
22193
+ out.push({
22194
+ sender,
22195
+ recipient: OPERATOR_PSEUDO_AGENT,
22196
+ timestampMs: Date.parse(entry.timestamp),
22197
+ auditId: `${entry.timestamp}:${entry.operation}`
22198
+ });
22199
+ }
22200
+ }
22201
+ return out;
22202
+ }
22203
+ function optionalString(details, key) {
22204
+ if (!details) return null;
22205
+ const value = details[key];
22206
+ if (typeof value !== "string" || value.length === 0) return null;
22207
+ return value;
22208
+ }
22209
+ var CROSS_AGENT_CHATTER_SENTINEL_ID, WARN_SIGMA2, ALERT_SIGMA2, BASELINE_WINDOWS2, QUERY_LIMIT2, MULTI_NEW_PARTNER_ALERT_THRESHOLD, OPERATOR_PSEUDO_AGENT, HANDOFF_OP, CROSS_HARNESS_OPS, CrossAgentChatterWatcher;
22210
+ var init_cross_agent_chatter_watcher = __esm({
22211
+ "src/sentinel/sentinels/cross-agent-chatter-watcher.ts"() {
22212
+ init_sentinel();
22213
+ CROSS_AGENT_CHATTER_SENTINEL_ID = "cross-agent-chatter";
22214
+ WARN_SIGMA2 = 3;
22215
+ ALERT_SIGMA2 = 6;
22216
+ BASELINE_WINDOWS2 = 7;
22217
+ QUERY_LIMIT2 = 1e4;
22218
+ MULTI_NEW_PARTNER_ALERT_THRESHOLD = 3;
22219
+ OPERATOR_PSEUDO_AGENT = "operator";
22220
+ HANDOFF_OP = "v1.1_local_handoff";
22221
+ CROSS_HARNESS_OPS = /* @__PURE__ */ new Set([
22222
+ "cross_harness_approval_aggregated",
22223
+ "cross_harness_approval_resolved"
22224
+ ]);
22225
+ CrossAgentChatterWatcher = class extends Sentinel {
22226
+ sentinelId = CROSS_AGENT_CHATTER_SENTINEL_ID;
22227
+ 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).";
22228
+ /** Pair keys we have already produced a baseline-established info finding for. */
22229
+ baselineEstablished = /* @__PURE__ */ new Set();
22230
+ async evaluate() {
22231
+ const ctx = this.requireContext();
22232
+ const now = ctx.now();
22233
+ const windowMs = 24 * 60 * 60 * 1e3;
22234
+ const windowSpanMs = (BASELINE_WINDOWS2 + 1) * windowMs;
22235
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22236
+ const queryResult = await ctx.auditLog.query({
22237
+ since: sinceIso,
22238
+ layer: "l2",
22239
+ limit: QUERY_LIMIT2
22240
+ });
22241
+ const events = extractInterAgentEvents(queryResult.entries);
22242
+ const byPair = /* @__PURE__ */ new Map();
22243
+ for (const event of events) {
22244
+ const auditAgeMs = now.getTime() - event.timestampMs;
22245
+ if (auditAgeMs < 0) continue;
22246
+ const windowIdx = Math.floor(auditAgeMs / windowMs);
22247
+ if (windowIdx > BASELINE_WINDOWS2) continue;
22248
+ const key = pairKey(event.sender, event.recipient);
22249
+ let snap = byPair.get(key);
22250
+ if (!snap) {
22251
+ snap = { windows: [] };
22252
+ for (let i = 0; i <= BASELINE_WINDOWS2; i += 1) {
22253
+ snap.windows.push({ count: 0, evidence_audit_ids: [] });
22254
+ }
22255
+ byPair.set(key, snap);
22256
+ }
22257
+ const bucket = snap.windows[windowIdx];
22258
+ bucket.count += 1;
22259
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22260
+ bucket.evidence_audit_ids.push(event.auditId);
22261
+ }
22262
+ }
22263
+ const findings = [];
22264
+ for (const [key, snap] of byPair.entries()) {
22265
+ const finding = this.evaluatePair(key, snap, now);
22266
+ if (finding) findings.push(finding);
22267
+ }
22268
+ const newPartnersBySource = computeNewPartners(byPair);
22269
+ for (const [source, partners] of newPartnersBySource.entries()) {
22270
+ const finding = this.buildNewPartnerFinding(source, partners, now);
22271
+ if (finding) findings.push(finding);
22272
+ }
22273
+ return findings;
22274
+ }
22275
+ /** Reset baseline-established memoization. Tests use this between runs. */
22276
+ resetBaselineMemo() {
22277
+ this.baselineEstablished.clear();
22278
+ }
22279
+ evaluatePair(key, snap, now) {
22280
+ const currentWindow = snap.windows[0];
22281
+ const baselineWindows = snap.windows.slice(1);
22282
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
22283
+ if (populated < BASELINE_WINDOWS2) {
22284
+ return null;
22285
+ }
22286
+ const counts = baselineWindows.map((w) => w.count);
22287
+ const mean = counts.reduce((s, c) => s + c, 0) / counts.length;
22288
+ const variance = counts.reduce((s, c) => s + (c - mean) ** 2, 0) / counts.length;
22289
+ const stddev = Math.sqrt(variance);
22290
+ const wasEstablished = this.baselineEstablished.has(key);
22291
+ this.baselineEstablished.add(key);
22292
+ if (!wasEstablished) {
22293
+ const pair = pairFromKey(key);
22294
+ return {
22295
+ finding_id: "",
22296
+ sentinel_id: this.sentinelId,
22297
+ severity: "info",
22298
+ 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).`,
22299
+ details: {
22300
+ sender_agent_id: pair.sender,
22301
+ recipient_agent_id: pair.recipient,
22302
+ baseline_mean: mean,
22303
+ baseline_stddev: stddev,
22304
+ baseline_windows: counts,
22305
+ current_count: currentWindow.count
22306
+ },
22307
+ observed_at: now.toISOString(),
22308
+ evidence_audit_ids: [],
22309
+ fortress_id: ""
22310
+ };
22311
+ }
22312
+ const warnThreshold = mean + WARN_SIGMA2 * stddev;
22313
+ const alertThreshold = mean + ALERT_SIGMA2 * stddev;
22314
+ if (currentWindow.count > alertThreshold) {
22315
+ return this.buildRateSpike(key, snap, mean, stddev, now, "alert", ALERT_SIGMA2);
22316
+ }
22317
+ if (currentWindow.count > warnThreshold) {
22318
+ return this.buildRateSpike(key, snap, mean, stddev, now, "warn", WARN_SIGMA2);
22319
+ }
22320
+ return null;
22321
+ }
22322
+ buildRateSpike(key, snap, mean, stddev, now, severity, sigma) {
22323
+ const pair = pairFromKey(key);
22324
+ const cur = snap.windows[0];
22325
+ const ratio = mean === 0 ? Infinity : cur.count / mean;
22326
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22327
+ 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.`;
22328
+ return {
22329
+ finding_id: "",
22330
+ sentinel_id: this.sentinelId,
22331
+ severity,
22332
+ summary,
22333
+ details: {
22334
+ sender_agent_id: pair.sender,
22335
+ recipient_agent_id: pair.recipient,
22336
+ current_count: cur.count,
22337
+ baseline_mean: mean,
22338
+ baseline_stddev: stddev,
22339
+ sigma_threshold: sigma,
22340
+ ratio
22341
+ },
22342
+ observed_at: now.toISOString(),
22343
+ agent_id: pair.sender,
22344
+ evidence_audit_ids: cur.evidence_audit_ids,
22345
+ fortress_id: ""
22346
+ };
22347
+ }
22348
+ buildNewPartnerFinding(source, info, now) {
22349
+ if (info.partners.length === 0) return null;
22350
+ const severity = info.partners.length >= MULTI_NEW_PARTNER_ALERT_THRESHOLD ? "alert" : "warn";
22351
+ const partnerList = info.partners.join(", ");
22352
+ const baselinePartnerList = info.priorPartners.length === 0 ? "no prior partners" : info.priorPartners.join(", ");
22353
+ 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}.`;
22354
+ return {
22355
+ finding_id: "",
22356
+ sentinel_id: this.sentinelId,
22357
+ severity,
22358
+ summary,
22359
+ details: {
22360
+ sender_agent_id: source,
22361
+ new_partners: info.partners,
22362
+ prior_partners: info.priorPartners,
22363
+ new_partner_count: info.partners.length,
22364
+ multi_new_partner_threshold: MULTI_NEW_PARTNER_ALERT_THRESHOLD
22365
+ },
22366
+ observed_at: now.toISOString(),
22367
+ agent_id: source,
22368
+ evidence_audit_ids: info.evidenceAuditIds,
22369
+ fortress_id: ""
22370
+ };
22371
+ }
22372
+ };
22373
+ }
22374
+ });
22375
+
22376
+ // src/sentinel/sentinels/credential-usage-watcher.ts
22377
+ function isCredentialAuditEntry(entry) {
22378
+ if (entry.result !== "success") return false;
22379
+ return entry.operation === BROKER_SECRET_READ_OP || entry.operation === BROKER_TOKEN_ISSUED_OP;
22380
+ }
22381
+ function extractAgentId(entry) {
22382
+ const details = entry.details;
22383
+ if (!details) return null;
22384
+ const agent = details["agent"];
22385
+ return typeof agent === "string" && agent.length > 0 ? agent : null;
22386
+ }
22387
+ function extractSecretId(entry) {
22388
+ const details = entry.details;
22389
+ if (!details) return null;
22390
+ const secret = details["secret"];
22391
+ return typeof secret === "string" && secret.length > 0 ? secret : null;
22392
+ }
22393
+ function enumerateUnorderedPairs(secrets) {
22394
+ const out = /* @__PURE__ */ new Set();
22395
+ const arr = [...secrets].sort();
22396
+ for (let i = 0; i < arr.length; i += 1) {
22397
+ for (let j = i + 1; j < arr.length; j += 1) {
22398
+ out.add(`${arr[i]}\0${arr[j]}`);
22399
+ }
22400
+ }
22401
+ return out;
22402
+ }
22403
+ function buildNewPairSummary(agentId, newPairs) {
22404
+ const first = newPairs[0];
22405
+ if (newPairs.length === 1) {
22406
+ return `${agentId} agent used ${first[0]} and ${first[1]} together for the first time today. This combination does not appear in historical sessions.`;
22407
+ }
22408
+ 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.`;
22409
+ }
22410
+ var CREDENTIAL_USAGE_SENTINEL_ID, WARN_SIGMA3, ALERT_SIGMA3, NEW_PAIR_ALERT_COUNT, BASELINE_WINDOWS3, QUERY_LIMIT3, BROKER_SECRET_READ_OP, BROKER_TOKEN_ISSUED_OP, CredentialUsageWatcher;
22411
+ var init_credential_usage_watcher = __esm({
22412
+ "src/sentinel/sentinels/credential-usage-watcher.ts"() {
22413
+ init_sentinel();
22414
+ CREDENTIAL_USAGE_SENTINEL_ID = "credential-usage";
22415
+ WARN_SIGMA3 = 3;
22416
+ ALERT_SIGMA3 = 6;
22417
+ NEW_PAIR_ALERT_COUNT = 3;
22418
+ BASELINE_WINDOWS3 = 7;
22419
+ QUERY_LIMIT3 = 2e4;
22420
+ BROKER_SECRET_READ_OP = "broker_secret_read";
22421
+ BROKER_TOKEN_ISSUED_OP = "broker_token_issued";
22422
+ CredentialUsageWatcher = class extends Sentinel {
22423
+ sentinelId = CREDENTIAL_USAGE_SENTINEL_ID;
22424
+ 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.";
22425
+ /**
22426
+ * Memoization of (agent, secret) pairs whose baseline has been
22427
+ * established. Same shape Phi-1 uses to avoid re-emitting `info`
22428
+ * findings on every tick after a baseline first establishes.
22429
+ *
22430
+ * Phi-2 deliberately does NOT emit `info` findings: per-pair
22431
+ * baselines on a busy fortress would be too noisy. The memo is
22432
+ * kept here for parity with Phi-1's reset hook so tests can clear
22433
+ * state between runs.
22434
+ */
22435
+ baselineEstablished = /* @__PURE__ */ new Set();
22436
+ /** Reset memoization. Tests use this between runs. */
22437
+ resetBaselineMemo() {
22438
+ this.baselineEstablished.clear();
22439
+ }
22440
+ async evaluate() {
22441
+ const ctx = this.requireContext();
22442
+ const now = ctx.now();
22443
+ const windowMs = 24 * 60 * 60 * 1e3;
22444
+ const windowSpanMs = (BASELINE_WINDOWS3 + 1) * windowMs;
22445
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22446
+ const queryResult = await ctx.auditLog.query({
22447
+ since: sinceIso,
22448
+ layer: "l3",
22449
+ limit: QUERY_LIMIT3
22450
+ });
22451
+ const entries = queryResult.entries.filter(isCredentialAuditEntry);
22452
+ const byPair = /* @__PURE__ */ new Map();
22453
+ const byAgent = /* @__PURE__ */ new Map();
22454
+ for (const entry of entries) {
22455
+ const agentId = extractAgentId(entry);
22456
+ const secretId = extractSecretId(entry);
22457
+ if (agentId === null || secretId === null) continue;
22458
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
22459
+ if (auditAge < 0) continue;
22460
+ const windowIdx = Math.floor(auditAge / windowMs);
22461
+ if (windowIdx > BASELINE_WINDOWS3) continue;
22462
+ const pairKey2 = `${agentId}\0${secretId}`;
22463
+ let pairSnapshot = byPair.get(pairKey2);
22464
+ if (!pairSnapshot) {
22465
+ pairSnapshot = {
22466
+ windows: Array.from({ length: BASELINE_WINDOWS3 + 1 }, () => ({
22467
+ count: 0,
22468
+ evidence_audit_ids: []
22469
+ }))
22470
+ };
22471
+ byPair.set(pairKey2, pairSnapshot);
22472
+ }
22473
+ const bucket = pairSnapshot.windows[windowIdx];
22474
+ bucket.count += 1;
22475
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22476
+ bucket.evidence_audit_ids.push(
22477
+ `${entry.timestamp}:${entry.operation}`
22478
+ );
22479
+ }
22480
+ let agentState = byAgent.get(agentId);
22481
+ if (!agentState) {
22482
+ agentState = {
22483
+ currentSecrets: /* @__PURE__ */ new Set(),
22484
+ currentEvidence: [],
22485
+ baselineSecretsByWindow: Array.from(
22486
+ { length: BASELINE_WINDOWS3 },
22487
+ () => /* @__PURE__ */ new Set()
22488
+ ),
22489
+ baselinePopulatedWindows: 0
22490
+ };
22491
+ byAgent.set(agentId, agentState);
22492
+ }
22493
+ if (windowIdx === 0) {
22494
+ agentState.currentSecrets.add(secretId);
22495
+ if (agentState.currentEvidence.length < 50) {
22496
+ agentState.currentEvidence.push(
22497
+ `${entry.timestamp}:${entry.operation}`
22498
+ );
22499
+ }
22500
+ } else {
22501
+ const baselineIdx = windowIdx - 1;
22502
+ agentState.baselineSecretsByWindow[baselineIdx].add(secretId);
22503
+ }
22504
+ }
22505
+ const findings = [];
22506
+ for (const [pairKey2, snapshot] of byPair.entries()) {
22507
+ const [agentId, secretId] = pairKey2.split("\0");
22508
+ const finding = this.evaluateRateSpike(
22509
+ agentId,
22510
+ secretId,
22511
+ snapshot,
22512
+ now
22513
+ );
22514
+ if (finding) findings.push(finding);
22515
+ }
22516
+ for (const [agentId, agentState] of byAgent.entries()) {
22517
+ agentState.baselinePopulatedWindows = agentState.baselineSecretsByWindow.filter((s) => s.size > 0).length;
22518
+ const finding = this.evaluateNewPairs(agentId, agentState, now);
22519
+ if (finding) findings.push(finding);
22520
+ }
22521
+ return findings;
22522
+ }
22523
+ evaluateRateSpike(agentId, secretId, snapshot, now) {
22524
+ const currentWindow = snapshot.windows[0];
22525
+ const baselineWindows = snapshot.windows.slice(1);
22526
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
22527
+ if (populated < BASELINE_WINDOWS3) {
22528
+ return null;
22529
+ }
22530
+ const counts = baselineWindows.map((w) => w.count);
22531
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
22532
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
22533
+ const stddev = Math.sqrt(variance);
22534
+ const pairKey2 = `${agentId}\0${secretId}`;
22535
+ this.baselineEstablished.add(pairKey2);
22536
+ const warnThreshold = mean + WARN_SIGMA3 * stddev;
22537
+ const alertThreshold = mean + ALERT_SIGMA3 * stddev;
22538
+ if (currentWindow.count > alertThreshold) {
22539
+ return this.buildRateFinding(
22540
+ agentId,
22541
+ secretId,
22542
+ currentWindow,
22543
+ mean,
22544
+ stddev,
22545
+ now,
22546
+ "alert",
22547
+ ALERT_SIGMA3
22548
+ );
22549
+ }
22550
+ if (currentWindow.count > warnThreshold) {
22551
+ return this.buildRateFinding(
22552
+ agentId,
22553
+ secretId,
22554
+ currentWindow,
22555
+ mean,
22556
+ stddev,
22557
+ now,
22558
+ "warn",
22559
+ WARN_SIGMA3
22560
+ );
22561
+ }
22562
+ return null;
22563
+ }
22564
+ buildRateFinding(agentId, secretId, currentWindow, mean, stddev, now, severity, sigma) {
22565
+ const ratio = mean === 0 ? Number.POSITIVE_INFINITY : currentWindow.count / mean;
22566
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22567
+ const summary = `${agentId} agent used ${secretId} ${currentWindow.count} times in last 24h, baseline ${mean.toFixed(1)} +/- ${stddev.toFixed(1)} (${ratioStr}). Crossed +${sigma} sigma threshold.`;
22568
+ return {
22569
+ finding_id: "",
22570
+ sentinel_id: this.sentinelId,
22571
+ severity,
22572
+ agent_id: agentId,
22573
+ summary,
22574
+ details: {
22575
+ agent_id: agentId,
22576
+ secret_id: secretId,
22577
+ current_count: currentWindow.count,
22578
+ baseline_mean: mean,
22579
+ baseline_stddev: stddev,
22580
+ sigma_threshold: sigma,
22581
+ ratio: Number.isFinite(ratio) ? ratio : null
22582
+ },
22583
+ observed_at: now.toISOString(),
22584
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
22585
+ fortress_id: ""
22586
+ };
22587
+ }
22588
+ evaluateNewPairs(agentId, state, now) {
22589
+ if (state.baselinePopulatedWindows < BASELINE_WINDOWS3) {
22590
+ return null;
22591
+ }
22592
+ if (state.currentSecrets.size < 2) return null;
22593
+ const currentPairs = enumerateUnorderedPairs(state.currentSecrets);
22594
+ const historicalPairs = /* @__PURE__ */ new Set();
22595
+ for (const secretSet of state.baselineSecretsByWindow) {
22596
+ for (const pair of enumerateUnorderedPairs(secretSet)) {
22597
+ historicalPairs.add(pair);
22598
+ }
22599
+ }
22600
+ const newPairs = [];
22601
+ for (const pair of currentPairs) {
22602
+ if (historicalPairs.has(pair)) continue;
22603
+ const [a, b] = pair.split("\0");
22604
+ newPairs.push([a, b]);
22605
+ }
22606
+ if (newPairs.length === 0) return null;
22607
+ const severity = newPairs.length >= NEW_PAIR_ALERT_COUNT ? "alert" : "warn";
22608
+ const summary = buildNewPairSummary(agentId, newPairs);
22609
+ return {
22610
+ finding_id: "",
22611
+ sentinel_id: this.sentinelId,
22612
+ severity,
22613
+ agent_id: agentId,
22614
+ summary,
22615
+ details: {
22616
+ agent_id: agentId,
22617
+ new_pairs: newPairs,
22618
+ new_pair_count: newPairs.length,
22619
+ historical_pair_count: historicalPairs.size,
22620
+ current_pair_count: currentPairs.size
22621
+ },
22622
+ observed_at: now.toISOString(),
22623
+ evidence_audit_ids: state.currentEvidence,
22624
+ fortress_id: ""
22625
+ };
22626
+ }
22627
+ };
22628
+ }
22629
+ });
22630
+
22631
+ // src/sentinel/sentinels/suspicious-tool-call-detector.ts
22632
+ function countTruncatedValues(args) {
22633
+ let n = 0;
22634
+ for (const v of Object.values(args)) {
22635
+ if (typeof v === "string" && v.endsWith("...")) n += 1;
22636
+ }
22637
+ return n;
22638
+ }
22639
+ function countUrlEncoded(value) {
22640
+ const matches = value.match(/%[0-9a-fA-F]{2}/g);
22641
+ return matches ? matches.length : 0;
22642
+ }
22643
+ function longestBase64Run(value) {
22644
+ const matches = value.match(/[A-Za-z0-9+/=]{40,}/g);
22645
+ if (!matches) return 0;
22646
+ return matches.reduce((max, m) => m.length > max ? m.length : max, 0);
22647
+ }
22648
+ function extractArgsSummary(details) {
22649
+ if (!details) return {};
22650
+ const summary = details["args_summary"];
22651
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
22652
+ return summary;
22653
+ }
22654
+ return {};
22655
+ }
22656
+ function truncateSummary2(s) {
22657
+ return s.length > 240 ? s.slice(0, 237) + "..." : s;
22658
+ }
22659
+ var SUSPICIOUS_TOOL_CALL_SENTINEL_ID, GATE_PREFIXES, WARN_SIGMA4, ALERT_SIGMA4, BASELINE_WINDOWS4, ALERT_NOVEL_COMBINATIONS, TASK_WINDOW_MS, TRUNCATION_WARN_THRESHOLD, QUERY_LIMIT4, SIGNATURE_PATTERNS, SuspiciousToolCallDetector;
22660
+ var init_suspicious_tool_call_detector = __esm({
22661
+ "src/sentinel/sentinels/suspicious-tool-call-detector.ts"() {
22662
+ init_sentinel();
22663
+ SUSPICIOUS_TOOL_CALL_SENTINEL_ID = "suspicious-tool-call";
22664
+ GATE_PREFIXES = [
22665
+ "gate_allow:",
22666
+ "gate_allow_proxy:",
22667
+ "gate_deny:",
22668
+ "gate_unclassified:"
22669
+ ];
22670
+ WARN_SIGMA4 = 3;
22671
+ ALERT_SIGMA4 = 6;
22672
+ BASELINE_WINDOWS4 = 7;
22673
+ ALERT_NOVEL_COMBINATIONS = 2;
22674
+ TASK_WINDOW_MS = 60 * 60 * 1e3;
22675
+ TRUNCATION_WARN_THRESHOLD = 5;
22676
+ QUERY_LIMIT4 = 1e4;
22677
+ SIGNATURE_PATTERNS = {
22678
+ /** >=5 percent-encoded sequences in a single visible value. */
22679
+ urlEncodedThreshold: 5,
22680
+ /** >=40 contiguous base64 chars in a single visible value. */
22681
+ base64MinRun: 40,
22682
+ /** Shell metacharacter set. */
22683
+ shellMetacharRegex: /(?:&&|\|\||;|\$\(|`|\|\s)/
22684
+ };
22685
+ SuspiciousToolCallDetector = class extends Sentinel {
22686
+ sentinelId = SUSPICIOUS_TOOL_CALL_SENTINEL_ID;
22687
+ 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.";
22688
+ /** Servers we have already produced an `info` baseline-established finding for. */
22689
+ baselineEstablished = /* @__PURE__ */ new Set();
22690
+ /** Memoized known novel-combination keys (sorted-tools-csv). */
22691
+ knownCombinations = /* @__PURE__ */ new Set();
22692
+ /** Tasks observed where a novel combination already produced a finding. */
22693
+ novelCombinationsReported = /* @__PURE__ */ new Set();
22694
+ async evaluate() {
22695
+ const ctx = this.requireContext();
22696
+ const now = ctx.now();
22697
+ const dayMs = 24 * 60 * 60 * 1e3;
22698
+ const windowSpanMs = (BASELINE_WINDOWS4 + 1) * dayMs;
22699
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22700
+ const queryResult = await ctx.auditLog.query({
22701
+ since: sinceIso,
22702
+ layer: "l2",
22703
+ limit: QUERY_LIMIT4
22704
+ });
22705
+ const observations = [];
22706
+ for (const entry of queryResult.entries) {
22707
+ const obs = this.observationFromEntry(entry);
22708
+ if (obs && obs.ts <= now.getTime()) observations.push(obs);
22709
+ }
22710
+ if (observations.length === 0) return [];
22711
+ const findings = [];
22712
+ const layerAFindings = await this.runLayerA(observations, now, ctx);
22713
+ findings.push(...layerAFindings);
22714
+ const layerBFindings = this.runLayerB(observations, now);
22715
+ findings.push(...layerBFindings);
22716
+ const layerCFindings = this.runLayerC(observations, now);
22717
+ findings.push(...layerCFindings);
22718
+ return findings;
22719
+ }
22720
+ /** Reset memoization between test runs. Mirrors Phi-1's reset hook. */
22721
+ resetMemo() {
22722
+ this.baselineEstablished.clear();
22723
+ this.knownCombinations.clear();
22724
+ this.novelCombinationsReported.clear();
22725
+ }
22726
+ // ── Layer A ───────────────────────────────────────────────────────
22727
+ async runLayerA(observations, now, ctx) {
22728
+ const dayMs = 24 * 60 * 60 * 1e3;
22729
+ const recent = observations.filter(
22730
+ (o) => now.getTime() - o.ts <= dayMs
22731
+ );
22732
+ if (recent.length === 0) return [];
22733
+ const findings = [];
22734
+ const historical = observations.filter(
22735
+ (o) => now.getTime() - o.ts > dayMs
22736
+ );
22737
+ const perTool = /* @__PURE__ */ new Map();
22738
+ const ensureTool = (tool) => {
22739
+ let w = perTool.get(tool);
22740
+ if (!w) {
22741
+ w = {
22742
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
22743
+ count: 0,
22744
+ evidenceIds: []
22745
+ })),
22746
+ knownSignatures: /* @__PURE__ */ new Set()
22747
+ };
22748
+ perTool.set(tool, w);
22749
+ }
22750
+ return w;
22751
+ };
22752
+ for (const o of historical) {
22753
+ ensureTool(o.tool).knownSignatures.add(this.signatureOf(o.argsSummary));
22754
+ }
22755
+ const classify = this.classifyHandle(ctx);
22756
+ for (const obs of recent) {
22757
+ const matches = this.matchSignatures(obs);
22758
+ if (matches.length === 0) continue;
22759
+ const ambiguous = matches.every((m) => m === "base64_chunk");
22760
+ if (ambiguous && classify) {
22761
+ const verdict = await this.consultClassifier(classify, obs);
22762
+ if (verdict !== "suspicious") continue;
22763
+ }
22764
+ findings.push(
22765
+ this.buildLayerAFinding(obs, matches, now, classify ? "llm-assist" : "rule-based")
22766
+ );
22767
+ }
22768
+ for (const obs of recent) {
22769
+ const tool = obs.tool;
22770
+ const sig = this.signatureOf(obs.argsSummary);
22771
+ const known = perTool.get(tool)?.knownSignatures;
22772
+ if (known && known.size > 0 && !known.has(sig)) {
22773
+ findings.push(
22774
+ this.buildNovelSignatureFinding(obs, sig, now)
22775
+ );
22776
+ }
22777
+ }
22778
+ return findings;
22779
+ }
22780
+ matchSignatures(obs) {
22781
+ const out = [];
22782
+ const truncCount = countTruncatedValues(obs.argsSummary);
22783
+ if (truncCount >= TRUNCATION_WARN_THRESHOLD) out.push("truncation_burst");
22784
+ let urlBlob = false;
22785
+ let base64Blob = false;
22786
+ let shellChars = false;
22787
+ for (const value of Object.values(obs.argsSummary)) {
22788
+ if (typeof value !== "string") continue;
22789
+ if (countUrlEncoded(value) >= SIGNATURE_PATTERNS.urlEncodedThreshold) {
22790
+ urlBlob = true;
22791
+ }
22792
+ if (longestBase64Run(value) >= SIGNATURE_PATTERNS.base64MinRun) {
22793
+ base64Blob = true;
22794
+ }
22795
+ if (SIGNATURE_PATTERNS.shellMetacharRegex.test(value)) {
22796
+ shellChars = true;
22797
+ }
22798
+ }
22799
+ if (urlBlob) out.push("url_encoded_blob");
22800
+ if (base64Blob) out.push("base64_chunk");
22801
+ if (shellChars) out.push("shell_metachar");
22802
+ return out;
22803
+ }
22804
+ signatureOf(argsSummary) {
22805
+ return Object.keys(argsSummary).sort().join(",");
22806
+ }
22807
+ buildLayerAFinding(obs, matches, now, detectionPath) {
22808
+ const severity = matches.includes("shell_metachar") ? "alert" : "warn";
22809
+ const summary = `${obs.tool}: tool-call argument matches signature ${matches.join(", ")} (${detectionPath}).`;
22810
+ return {
22811
+ finding_id: "",
22812
+ sentinel_id: this.sentinelId,
22813
+ severity,
22814
+ summary: truncateSummary2(summary),
22815
+ details: {
22816
+ layer: "A",
22817
+ tool: obs.tool,
22818
+ proxy: obs.proxy,
22819
+ signatures: matches,
22820
+ detection_path: detectionPath
22821
+ },
22822
+ observed_at: now.toISOString(),
22823
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
22824
+ fortress_id: ""
22825
+ };
22826
+ }
22827
+ buildNovelSignatureFinding(obs, signature, now) {
22828
+ return {
22829
+ finding_id: "",
22830
+ sentinel_id: this.sentinelId,
22831
+ severity: "warn",
22832
+ summary: truncateSummary2(
22833
+ `${obs.tool}: novel argument-key signature observed (${signature || "<no-args>"}).`
22834
+ ),
22835
+ details: {
22836
+ layer: "A",
22837
+ tool: obs.tool,
22838
+ proxy: obs.proxy,
22839
+ signatures: ["novel_signature"],
22840
+ detection_path: "rule-based",
22841
+ novel_signature: signature
22842
+ },
22843
+ observed_at: now.toISOString(),
22844
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
22845
+ fortress_id: ""
22846
+ };
22847
+ }
22848
+ // ── Layer B ───────────────────────────────────────────────────────
22849
+ runLayerB(observations, now) {
22850
+ const dayMs = 24 * 60 * 60 * 1e3;
22851
+ const perTool = /* @__PURE__ */ new Map();
22852
+ for (const obs of observations) {
22853
+ const ageMs = now.getTime() - obs.ts;
22854
+ if (ageMs < 0) continue;
22855
+ const windowIdx = Math.floor(ageMs / dayMs);
22856
+ if (windowIdx > BASELINE_WINDOWS4) continue;
22857
+ let w = perTool.get(obs.tool);
22858
+ if (!w) {
22859
+ w = {
22860
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
22861
+ count: 0,
22862
+ evidenceIds: []
22863
+ })),
22864
+ knownSignatures: /* @__PURE__ */ new Set()
22865
+ };
22866
+ perTool.set(obs.tool, w);
22867
+ }
22868
+ const bucket = w.windows[windowIdx];
22869
+ bucket.count += 1;
22870
+ if (windowIdx === 0 && bucket.evidenceIds.length < 50) {
22871
+ bucket.evidenceIds.push(`${obs.entry.timestamp}:${obs.entry.operation}`);
22872
+ }
22873
+ }
22874
+ const findings = [];
22875
+ for (const [tool, w] of perTool.entries()) {
22876
+ const f = this.evaluateToolFrequency(tool, w, now);
22877
+ if (f) findings.push(f);
22878
+ }
22879
+ return findings;
22880
+ }
22881
+ evaluateToolFrequency(tool, w, now) {
22882
+ const current = w.windows[0];
22883
+ const baseline = w.windows.slice(1);
22884
+ const populated = baseline.filter((b) => b.count > 0).length;
22885
+ if (populated < BASELINE_WINDOWS4) {
22886
+ this.baselineEstablished.add(tool);
22887
+ return null;
22888
+ }
22889
+ const counts = baseline.map((b) => b.count);
22890
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
22891
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
22892
+ const stddev = Math.sqrt(variance);
22893
+ const wasEstablished = this.baselineEstablished.has(tool);
22894
+ this.baselineEstablished.add(tool);
22895
+ if (!wasEstablished) {
22896
+ return {
22897
+ finding_id: "",
22898
+ sentinel_id: this.sentinelId,
22899
+ severity: "info",
22900
+ summary: truncateSummary2(
22901
+ `${tool}: tool-call baseline established (mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} over ${BASELINE_WINDOWS4} prior days).`
22902
+ ),
22903
+ details: {
22904
+ layer: "B",
22905
+ tool,
22906
+ baseline_mean: mean,
22907
+ baseline_stddev: stddev,
22908
+ baseline_counts: counts,
22909
+ current_count: current.count
22910
+ },
22911
+ observed_at: now.toISOString(),
22912
+ evidence_audit_ids: [],
22913
+ fortress_id: ""
22914
+ };
22915
+ }
22916
+ const warnT = mean + WARN_SIGMA4 * stddev;
22917
+ const alertT = mean + ALERT_SIGMA4 * stddev;
22918
+ if (current.count > alertT) {
22919
+ return this.buildLayerBAnomaly(
22920
+ tool,
22921
+ current,
22922
+ mean,
22923
+ stddev,
22924
+ ALERT_SIGMA4,
22925
+ "alert",
22926
+ now
22927
+ );
22928
+ }
22929
+ if (current.count > warnT) {
22930
+ return this.buildLayerBAnomaly(
22931
+ tool,
22932
+ current,
22933
+ mean,
22934
+ stddev,
22935
+ WARN_SIGMA4,
22936
+ "warn",
22937
+ now
22938
+ );
22939
+ }
22940
+ return null;
22941
+ }
22942
+ buildLayerBAnomaly(tool, current, mean, stddev, sigma, severity, now) {
22943
+ const ratio = mean === 0 ? Infinity : current.count / mean;
22944
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22945
+ return {
22946
+ finding_id: "",
22947
+ sentinel_id: this.sentinelId,
22948
+ severity,
22949
+ summary: truncateSummary2(
22950
+ `${tool}: tool-call rate is ${ratioStr}: ${current.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma.`
22951
+ ),
22952
+ details: {
22953
+ layer: "B",
22954
+ tool,
22955
+ current_count: current.count,
22956
+ baseline_mean: mean,
22957
+ baseline_stddev: stddev,
22958
+ sigma_threshold: sigma,
22959
+ ratio
22960
+ },
22961
+ observed_at: now.toISOString(),
22962
+ evidence_audit_ids: current.evidenceIds,
22963
+ fortress_id: ""
22964
+ };
22965
+ }
22966
+ // ── Layer C ───────────────────────────────────────────────────────
22967
+ runLayerC(observations, now) {
22968
+ const dayMs = 24 * 60 * 60 * 1e3;
22969
+ const sorted = [...observations].sort((a, b) => a.ts - b.ts);
22970
+ const tasks = [];
22971
+ for (const obs of sorted) {
22972
+ const last = tasks[tasks.length - 1];
22973
+ if (!last || obs.ts - last.startTs > TASK_WINDOW_MS) {
22974
+ tasks.push({ startTs: obs.ts, tools: [obs.tool] });
22975
+ continue;
22976
+ }
22977
+ if (!last.tools.includes(obs.tool)) last.tools.push(obs.tool);
22978
+ }
22979
+ const recentTaskKeys = [];
22980
+ const findings = [];
22981
+ for (const task of tasks) {
22982
+ const ageMs = now.getTime() - task.startTs;
22983
+ const key = task.tools.slice().sort().join(",");
22984
+ if (ageMs > dayMs) {
22985
+ this.knownCombinations.add(key);
22986
+ continue;
22987
+ }
22988
+ if (task.tools.length < 2) continue;
22989
+ if (!this.knownCombinations.has(key)) {
22990
+ this.knownCombinations.add(key);
22991
+ if (!this.novelCombinationsReported.has(key)) {
22992
+ this.novelCombinationsReported.add(key);
22993
+ recentTaskKeys.push(key);
22994
+ findings.push(
22995
+ this.buildLayerCFinding(task, key, "warn", now)
22996
+ );
22997
+ }
22998
+ }
22999
+ }
23000
+ if (recentTaskKeys.length >= ALERT_NOVEL_COMBINATIONS) {
23001
+ const aggregate = {
23002
+ finding_id: "",
23003
+ sentinel_id: this.sentinelId,
23004
+ severity: "alert",
23005
+ summary: truncateSummary2(
23006
+ `multi-novel-combination: ${recentTaskKeys.length} novel tool-permission combinations within last 24h.`
23007
+ ),
23008
+ details: {
23009
+ layer: "C",
23010
+ novel_combinations: recentTaskKeys
23011
+ },
23012
+ observed_at: now.toISOString(),
23013
+ evidence_audit_ids: [],
23014
+ fortress_id: ""
23015
+ };
23016
+ findings.push(aggregate);
23017
+ }
23018
+ return findings;
23019
+ }
23020
+ buildLayerCFinding(task, key, severity, now) {
23021
+ return {
23022
+ finding_id: "",
23023
+ sentinel_id: this.sentinelId,
23024
+ severity,
23025
+ summary: truncateSummary2(
23026
+ `novel-permission-combination: tools=[${task.tools.join(",")}] observed in single task burst (${task.tools.length} distinct tools).`
23027
+ ),
23028
+ details: {
23029
+ layer: "C",
23030
+ combination_key: key,
23031
+ tools: task.tools,
23032
+ task_started_at: new Date(task.startTs).toISOString()
23033
+ },
23034
+ observed_at: now.toISOString(),
23035
+ evidence_audit_ids: [],
23036
+ fortress_id: ""
23037
+ };
23038
+ }
23039
+ // ── LLM-assist ────────────────────────────────────────────────────
23040
+ classifyHandle(ctx) {
23041
+ const selector = ctx.substrateSelector;
23042
+ if (!selector) return null;
23043
+ const fn = selector.invokeClassify;
23044
+ if (typeof fn !== "function") return null;
23045
+ return async (items) => {
23046
+ try {
23047
+ const resp = await fn.call(selector, "sentinel-scoring", {
23048
+ kind: "classify",
23049
+ items,
23050
+ categories: ["benign", "suspicious"]
23051
+ });
23052
+ if (resp.failureClass) return { kind: "failure", message: "substrate failure" };
23053
+ if (resp.body.kind === "classify") {
23054
+ return { kind: "classify", results: resp.body.results };
23055
+ }
23056
+ return { kind: "failure", message: resp.body.message };
23057
+ } catch {
23058
+ return null;
23059
+ }
23060
+ };
23061
+ }
23062
+ async consultClassifier(classify, obs) {
23063
+ const item = JSON.stringify({
23064
+ tool: obs.tool,
23065
+ proxy: obs.proxy,
23066
+ args_summary: obs.argsSummary
23067
+ });
23068
+ const result = await classify([item]);
23069
+ if (!result || result.kind !== "classify") return "unknown";
23070
+ const top = result.results[0];
23071
+ if (!top) return "unknown";
23072
+ if (top.category === "suspicious" && top.confidence >= 0.5) {
23073
+ return "suspicious";
23074
+ }
23075
+ if (top.category === "benign") return "benign";
23076
+ return "unknown";
23077
+ }
23078
+ // ── helpers ───────────────────────────────────────────────────────
23079
+ observationFromEntry(entry) {
23080
+ const op = entry.operation;
23081
+ let tool = null;
23082
+ let proxy = false;
23083
+ for (const prefix of GATE_PREFIXES) {
23084
+ if (op.startsWith(prefix)) {
23085
+ tool = op.slice(prefix.length);
23086
+ proxy = prefix === "gate_allow_proxy:";
23087
+ break;
23088
+ }
23089
+ }
23090
+ if (!tool) return null;
23091
+ const ts = Date.parse(entry.timestamp);
23092
+ if (!Number.isFinite(ts)) return null;
23093
+ const argsSummary = extractArgsSummary(entry.details);
23094
+ return { tool, proxy, ts, entry, argsSummary };
23095
+ }
23096
+ };
23097
+ }
23098
+ });
23099
+
23100
+ // src/sentinel/sentinels/index.ts
23101
+ var PHI1_BASELINE_CATALOG;
23102
+ var init_sentinels = __esm({
23103
+ "src/sentinel/sentinels/index.ts"() {
23104
+ init_egress_volume_watcher();
23105
+ init_cross_agent_chatter_watcher();
23106
+ init_credential_usage_watcher();
23107
+ init_suspicious_tool_call_detector();
23108
+ PHI1_BASELINE_CATALOG = [
23109
+ {
23110
+ sentinelId: EGRESS_VOLUME_SENTINEL_ID,
23111
+ description: "Watches outbound proxy-call volume per upstream server and surfaces anomalous spikes against a rolling 7-day baseline.",
23112
+ factory: () => new EgressVolumeWatcher()
23113
+ },
23114
+ {
23115
+ sentinelId: CROSS_AGENT_CHATTER_SENTINEL_ID,
23116
+ 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.",
23117
+ factory: () => new CrossAgentChatterWatcher()
23118
+ },
23119
+ {
23120
+ sentinelId: CREDENTIAL_USAGE_SENTINEL_ID,
23121
+ 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.",
23122
+ factory: () => new CredentialUsageWatcher()
23123
+ },
23124
+ {
23125
+ sentinelId: SUSPICIOUS_TOOL_CALL_SENTINEL_ID,
23126
+ description: "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history.",
23127
+ factory: () => new SuspiciousToolCallDetector()
23128
+ }
23129
+ ];
23130
+ }
23131
+ });
23132
+ function sentinelSubscriptionsPath(storagePath) {
23133
+ return join(storagePath, "sentinel-subscriptions.json");
23134
+ }
23135
+ async function loadSentinelSubscriptions(storagePath) {
23136
+ const filePath = sentinelSubscriptionsPath(storagePath);
23137
+ try {
23138
+ const raw = await readFile(filePath, "utf8");
23139
+ const parsed = JSON.parse(raw);
23140
+ if (parsed.version !== FILE_VERSION) return /* @__PURE__ */ new Set();
23141
+ if (!Array.isArray(parsed.subscribed)) return /* @__PURE__ */ new Set();
23142
+ const cleaned = parsed.subscribed.filter(
23143
+ (id) => typeof id === "string" && id.length > 0
23144
+ );
23145
+ return new Set(cleaned);
23146
+ } catch {
23147
+ return /* @__PURE__ */ new Set();
23148
+ }
23149
+ }
23150
+ async function saveSentinelSubscriptions(storagePath, subscribed) {
23151
+ const filePath = sentinelSubscriptionsPath(storagePath);
23152
+ await mkdir(dirname(filePath), { recursive: true });
23153
+ const payload = {
23154
+ version: FILE_VERSION,
23155
+ subscribed: [...new Set(subscribed)].filter((s) => s.length > 0).sort()
23156
+ };
23157
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}
23158
+ `, {
23159
+ mode: 384
23160
+ });
23161
+ }
23162
+ var FILE_VERSION;
23163
+ var init_subscription_store = __esm({
23164
+ "src/sentinel/subscription-store.ts"() {
23165
+ FILE_VERSION = 1;
23166
+ }
23167
+ });
23168
+
21134
23169
  // src/principal-policy/tools.ts
21135
23170
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
21136
23171
  return [
@@ -32608,7 +34643,7 @@ var init_recovery_key_disclosure = __esm({
32608
34643
  });
32609
34644
 
32610
34645
  // src/hub/types.ts
32611
- var init_types3 = __esm({
34646
+ var init_types4 = __esm({
32612
34647
  "src/hub/types.ts"() {
32613
34648
  }
32614
34649
  });
@@ -33647,7 +35682,7 @@ var init_hub = __esm({
33647
35682
  "src/hub/index.ts"() {
33648
35683
  init_constants3();
33649
35684
  init_errors4();
33650
- init_types3();
35685
+ init_types4();
33651
35686
  init_agent_registry();
33652
35687
  init_inbox_store();
33653
35688
  init_inbox_aggregator();
@@ -33756,7 +35791,17 @@ var init_operator_chat_audit_events = __esm({
33756
35791
  * fold. The concierge omits that category and continues; the user-
33757
35792
  * facing query is never broken. Body carries category + failure_reason.
33758
35793
  */
33759
- CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed"
35794
+ CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed",
35795
+ /**
35796
+ * Concierge surfaced a proactive starter when a fresh conversation
35797
+ * thread opened (WP-V1.3-9 Tau-5). Emitted once per starter, never on
35798
+ * follow-up turns within the same thread. Body carries `thread_id`,
35799
+ * `trigger` (stable enum), and `triggered_agents_count`. The starter
35800
+ * text body is NOT carried; the trigger enum is sufficient for
35801
+ * dashboard grouping and keeps fortress-internal agent ids off the
35802
+ * audit surface.
35803
+ */
35804
+ CONCIERGE_PROACTIVE_SUGGESTION_OFFERED: "operator_concierge_proactive_suggestion_offered"
33760
35805
  };
33761
35806
  }
33762
35807
  });
@@ -33789,9 +35834,10 @@ function isTrivialQuery(query) {
33789
35834
  if (norm.length < 8) return true;
33790
35835
  return TRIVIAL_GREETINGS.has(norm);
33791
35836
  }
33792
- function classifyQuery(query) {
35837
+ function classifyQuery(query, parsedGrammar) {
33793
35838
  const normalized = query.toLowerCase();
33794
35839
  const matches = [];
35840
+ const grammarAgent = parsedGrammar && parsedGrammar.agent_names.length > 0 ? parsedGrammar.agent_names[0] ?? null : null;
33795
35841
  for (const spec of CATEGORY_KEYWORDS) {
33796
35842
  const matchedPhrases = [];
33797
35843
  for (const pattern of spec.patterns) {
@@ -33803,11 +35849,14 @@ function classifyQuery(query) {
33803
35849
  }
33804
35850
  if (matchedPhrases.length === 0) continue;
33805
35851
  const confidence = Math.min(1, 0.4 + 0.3 * matchedPhrases.length);
35852
+ const wantsAgentHint = spec.category === "agent_state" || spec.category === "agent_activity";
35853
+ const agent_name_hint = wantsAgentHint ? grammarAgent ?? extractAgentNameHint(query) : null;
33806
35854
  matches.push({
33807
35855
  category: spec.category,
33808
35856
  confidence,
33809
35857
  matched_keywords: matchedPhrases,
33810
- agent_name_hint: spec.category === "agent_state" || spec.category === "agent_activity" ? extractAgentNameHint(query) : null
35858
+ agent_name_hint,
35859
+ ...parsedGrammar !== void 0 ? { query_grammar: parsedGrammar } : {}
33811
35860
  });
33812
35861
  }
33813
35862
  matches.sort((a, b) => {
@@ -33816,45 +35865,67 @@ function classifyQuery(query) {
33816
35865
  });
33817
35866
  return matches;
33818
35867
  }
35868
+ function fetcherHintsFromGrammar(parsed) {
35869
+ if (!parsed) return void 0;
35870
+ const hasTime = parsed.time_range !== null;
35871
+ const hasAgents = parsed.agent_names.length > 0;
35872
+ const hasEvents = parsed.event_types.length > 0;
35873
+ if (!hasTime && !hasAgents && !hasEvents) return void 0;
35874
+ const hints = {};
35875
+ if (parsed.time_range) {
35876
+ const range = parsed.time_range;
35877
+ hints.time_range = {
35878
+ start: range.start,
35879
+ end: range.end,
35880
+ ...range.relative_label !== void 0 ? { relative_label: range.relative_label } : {}
35881
+ };
35882
+ }
35883
+ if (hasAgents) hints.agent_names = parsed.agent_names;
35884
+ if (hasEvents) hints.event_types = parsed.event_types;
35885
+ return hints;
35886
+ }
33819
35887
  function approxTokenLen(text) {
33820
35888
  return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
33821
35889
  }
33822
- async function runFetcher(match, fetchers) {
35890
+ async function runFetcher(match, fetchers, hints) {
33823
35891
  switch (match.category) {
33824
35892
  case "templates":
33825
- return fetchers.templates();
35893
+ return fetchers.templates(hints);
33826
35894
  case "agent_state":
33827
- return fetchers.agent_state(match.agent_name_hint);
35895
+ return fetchers.agent_state(match.agent_name_hint, hints);
33828
35896
  case "agent_activity":
33829
- return fetchers.agent_activity(match.agent_name_hint);
35897
+ return fetchers.agent_activity(match.agent_name_hint, hints);
33830
35898
  case "audit_log":
33831
- return fetchers.audit_log();
35899
+ return fetchers.audit_log(hints);
33832
35900
  case "sentinel_findings":
33833
- return fetchers.sentinel_findings();
35901
+ return fetchers.sentinel_findings(hints);
33834
35902
  case "anomaly_alerts":
33835
- return fetchers.anomaly_alerts();
35903
+ return fetchers.anomaly_alerts(hints);
33836
35904
  case "recent_receipts":
33837
- return fetchers.recent_receipts();
35905
+ return fetchers.recent_receipts(hints);
33838
35906
  case "verascore_deltas":
33839
- return fetchers.verascore_deltas();
35907
+ return fetchers.verascore_deltas(hints);
33840
35908
  }
33841
35909
  }
33842
- function trivialMatch(category) {
35910
+ function trivialMatch(category, parsedGrammar) {
33843
35911
  return {
33844
35912
  category,
33845
35913
  confidence: 0.5,
33846
35914
  matched_keywords: ["llm-assist"],
33847
- agent_name_hint: null
35915
+ agent_name_hint: parsedGrammar && parsedGrammar.agent_names.length > 0 ? parsedGrammar.agent_names[0] ?? null : null,
35916
+ ...parsedGrammar !== void 0 ? { query_grammar: parsedGrammar } : {}
33848
35917
  };
33849
35918
  }
33850
35919
  async function foldContext(query, fetchers, opts) {
33851
35920
  const budget = opts?.maxTokens ?? DEFAULT_DYNAMIC_CONTEXT_TOKEN_BUDGET;
33852
- let matches = classifyQuery(query);
35921
+ const parsed = opts?.parsed ?? null;
35922
+ const hints = fetcherHintsFromGrammar(parsed);
35923
+ let matches = classifyQuery(query, parsed);
33853
35924
  if (matches.length === 0 && !isTrivialQuery(query) && opts?.llmAssistClassify) {
33854
35925
  try {
33855
35926
  const picked = await opts.llmAssistClassify(query, CONTEXT_CATEGORIES);
33856
35927
  if (picked !== "none" && CONTEXT_CATEGORIES.includes(picked)) {
33857
- matches = [trivialMatch(picked)];
35928
+ matches = [trivialMatch(picked, parsed)];
33858
35929
  }
33859
35930
  } catch {
33860
35931
  }
@@ -33865,7 +35936,7 @@ async function foldContext(query, fetchers, opts) {
33865
35936
  const attempts = [];
33866
35937
  for (const match of matches) {
33867
35938
  try {
33868
- const text = await runFetcher(match, fetchers);
35939
+ const text = await runFetcher(match, fetchers, hints);
33869
35940
  const trimmed = text.trim();
33870
35941
  if (trimmed.length > 0) {
33871
35942
  attempts.push({ category: match.category, text: trimmed });
@@ -34037,9 +36108,635 @@ var init_concierge_context_router = __esm({
34037
36108
  };
34038
36109
  }
34039
36110
  });
36111
+
36112
+ // src/composition/constants.ts
36113
+ var COMPOSITION_EVENT_TYPES;
36114
+ var init_constants4 = __esm({
36115
+ "src/composition/constants.ts"() {
36116
+ init_constants();
36117
+ COMPOSITION_EVENT_TYPES = [
36118
+ "composition_receipt_packed",
36119
+ "composition_receipt_verified",
36120
+ "composition_mandate_verified",
36121
+ "composition_verascore_published",
36122
+ "composition_sidecar_spawned",
36123
+ "composition_sidecar_crashed",
36124
+ "composition_sidecar_recovered",
36125
+ "composition_degraded",
36126
+ "composition_recovered"
36127
+ ];
36128
+ }
36129
+ });
36130
+
36131
+ // src/chat/concierge-query-grammar.ts
36132
+ function resolveTimeRange(query, now) {
36133
+ const normalized = query.trim();
36134
+ const lower = normalized.toLowerCase();
36135
+ const fromTo = lower.match(
36136
+ /\b(?:from|between)\s+(.+?)\s+(?:to|and|-|until)\s+([\w:.\-+t /]+)/i
36137
+ );
36138
+ if (fromTo) {
36139
+ const aSlice = fromTo[1];
36140
+ const bSlice = fromTo[2];
36141
+ if (aSlice !== void 0 && bSlice !== void 0) {
36142
+ const a = parseInstant(aSlice, now);
36143
+ const b = parseInstant(bSlice, now);
36144
+ if (a && b) {
36145
+ const start = a.getTime() <= b.getTime() ? a : b;
36146
+ const end = a.getTime() <= b.getTime() ? b : a;
36147
+ return {
36148
+ range: { start, end },
36149
+ matchedSubstring: fromTo[0]
36150
+ };
36151
+ }
36152
+ }
36153
+ }
36154
+ const sinceMatch = lower.match(/\bsince\s+([\w:.\-+t /]+)/i);
36155
+ if (sinceMatch) {
36156
+ const slice = sinceMatch[1];
36157
+ if (slice !== void 0) {
36158
+ const start = parseInstant(slice, now);
36159
+ if (start) {
36160
+ return {
36161
+ range: { start, end: now },
36162
+ matchedSubstring: sinceMatch[0]
36163
+ };
36164
+ }
36165
+ }
36166
+ }
36167
+ if (/\byesterday\b/.test(lower)) {
36168
+ const startOfToday = startOfDay(now);
36169
+ const start = new Date(startOfToday.getTime() - MS_PER_DAY);
36170
+ const end = new Date(startOfToday.getTime() - 1);
36171
+ return {
36172
+ range: { start, end, relative_label: "yesterday" },
36173
+ matchedSubstring: "yesterday"
36174
+ };
36175
+ }
36176
+ if (/\btoday\b/.test(lower)) {
36177
+ return {
36178
+ range: {
36179
+ start: startOfDay(now),
36180
+ end: now,
36181
+ relative_label: "today"
36182
+ },
36183
+ matchedSubstring: "today"
36184
+ };
36185
+ }
36186
+ const compactHours = lower.match(/\blast\s+(\d+)\s*h\b/i);
36187
+ if (compactHours) {
36188
+ const tok = compactHours[1];
36189
+ if (tok !== void 0) {
36190
+ const n = Number.parseInt(tok, 10);
36191
+ if (Number.isFinite(n) && n > 0) {
36192
+ const start = new Date(now.getTime() - n * MS_PER_HOUR);
36193
+ return {
36194
+ range: { start, end: now, relative_label: `last ${n}h` },
36195
+ matchedSubstring: compactHours[0]
36196
+ };
36197
+ }
36198
+ }
36199
+ }
36200
+ const hoursMatch = lower.match(
36201
+ /\b(?:past|last)\s+([\w]+|\d+)\s*(?:hr\b|hrs\b|hour|hours)/i
36202
+ );
36203
+ if (hoursMatch) {
36204
+ const tok = hoursMatch[1];
36205
+ if (tok !== void 0) {
36206
+ const n = parseCount(tok);
36207
+ if (n !== null && n > 0) {
36208
+ const start = new Date(now.getTime() - n * MS_PER_HOUR);
36209
+ return {
36210
+ range: { start, end: now, relative_label: `past ${n} hour${n === 1 ? "" : "s"}` },
36211
+ matchedSubstring: hoursMatch[0]
36212
+ };
36213
+ }
36214
+ }
36215
+ }
36216
+ if (/\b(?:past|last)\s+hour\b/.test(lower)) {
36217
+ const start = new Date(now.getTime() - MS_PER_HOUR);
36218
+ return {
36219
+ range: { start, end: now, relative_label: "past hour" },
36220
+ matchedSubstring: lower.match(/\b(?:past|last)\s+hour\b/i)[0]
36221
+ };
36222
+ }
36223
+ const daysMatch = lower.match(
36224
+ /\b(?:past|last)\s+([\w]+|\d+)\s*(?:d\b|day|days)/i
36225
+ );
36226
+ if (daysMatch) {
36227
+ const tok = daysMatch[1];
36228
+ if (tok !== void 0) {
36229
+ const n = parseCount(tok);
36230
+ if (n !== null && n > 0) {
36231
+ const start = new Date(now.getTime() - n * MS_PER_DAY);
36232
+ return {
36233
+ range: { start, end: now, relative_label: `past ${n} day${n === 1 ? "" : "s"}` },
36234
+ matchedSubstring: daysMatch[0]
36235
+ };
36236
+ }
36237
+ }
36238
+ }
36239
+ if (/\b(?:past|last)\s+day\b/.test(lower)) {
36240
+ const start = new Date(now.getTime() - MS_PER_DAY);
36241
+ return {
36242
+ range: { start, end: now, relative_label: "past day" },
36243
+ matchedSubstring: lower.match(/\b(?:past|last)\s+day\b/i)[0]
36244
+ };
36245
+ }
36246
+ if (/\bthis\s+week\b/.test(lower)) {
36247
+ const start = startOfWeek(now);
36248
+ return {
36249
+ range: { start, end: now, relative_label: "this week" },
36250
+ matchedSubstring: lower.match(/\bthis\s+week\b/i)[0]
36251
+ };
36252
+ }
36253
+ if (/\b(?:past|last)\s+week\b/.test(lower)) {
36254
+ const start = new Date(now.getTime() - 7 * MS_PER_DAY);
36255
+ return {
36256
+ range: { start, end: now, relative_label: "past week" },
36257
+ matchedSubstring: lower.match(/\b(?:past|last)\s+week\b/i)[0]
36258
+ };
36259
+ }
36260
+ const isoMatch = normalized.match(
36261
+ /\b(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?)?)\b/
36262
+ );
36263
+ if (isoMatch) {
36264
+ const tok = isoMatch[1];
36265
+ if (tok !== void 0) {
36266
+ const parsed = parseInstant(tok, now);
36267
+ if (parsed) {
36268
+ const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(tok);
36269
+ if (isDateOnly) {
36270
+ return {
36271
+ range: {
36272
+ start: parsed,
36273
+ end: new Date(parsed.getTime() + MS_PER_DAY - 1)
36274
+ },
36275
+ matchedSubstring: tok
36276
+ };
36277
+ }
36278
+ return {
36279
+ range: {
36280
+ start: new Date(parsed.getTime() - 30 * 60 * 1e3),
36281
+ end: new Date(parsed.getTime() + 30 * 60 * 1e3)
36282
+ },
36283
+ matchedSubstring: tok
36284
+ };
36285
+ }
36286
+ }
36287
+ }
36288
+ return null;
36289
+ }
36290
+ function parseInstant(token, now) {
36291
+ const trimmed = token.trim().replace(/[,.!?;]+$/g, "");
36292
+ if (!trimmed) return null;
36293
+ const lower = trimmed.toLowerCase();
36294
+ if (lower === "now") return now;
36295
+ if (lower === "today") return startOfDay(now);
36296
+ if (lower === "yesterday") {
36297
+ return new Date(startOfDay(now).getTime() - MS_PER_DAY);
36298
+ }
36299
+ const isoLike = trimmed.replace(" ", "T");
36300
+ const parsed = new Date(isoLike);
36301
+ if (!Number.isNaN(parsed.getTime())) return parsed;
36302
+ return null;
36303
+ }
36304
+ function parseCount(token) {
36305
+ const lower = token.toLowerCase();
36306
+ if (/^\d+$/.test(lower)) {
36307
+ const n = Number.parseInt(lower, 10);
36308
+ return Number.isFinite(n) ? n : null;
36309
+ }
36310
+ return NUMBER_WORDS[lower] ?? null;
36311
+ }
36312
+ function startOfDay(d) {
36313
+ const out = new Date(d);
36314
+ out.setHours(0, 0, 0, 0);
36315
+ return out;
36316
+ }
36317
+ function startOfWeek(d) {
36318
+ const out = startOfDay(d);
36319
+ const dayOfWeek = out.getDay();
36320
+ const offsetToMonday = (dayOfWeek + 6) % 7;
36321
+ out.setDate(out.getDate() - offsetToMonday);
36322
+ return out;
36323
+ }
36324
+ function listFromRegistry(registry) {
36325
+ if (!registry) return [];
36326
+ if (Array.isArray(registry)) return registry;
36327
+ if (typeof registry.list === "function") {
36328
+ return registry.list();
36329
+ }
36330
+ return [];
36331
+ }
36332
+ function extractAgentNames(query, registry) {
36333
+ const records = listFromRegistry(registry);
36334
+ if (records.length === 0) return { matched: [], flagged: false };
36335
+ const lowerQuery = query.toLowerCase();
36336
+ const compactQuery = lowerQuery.replace(/[\s_-]+/g, "");
36337
+ const matched = [];
36338
+ const seen = /* @__PURE__ */ new Set();
36339
+ for (const rec of records) {
36340
+ const id = rec.agent_id;
36341
+ if (!id || seen.has(id)) continue;
36342
+ const idLower = id.toLowerCase();
36343
+ if (idLower.length < 3) continue;
36344
+ const idCompact = idLower.replace(/[\s_-]+/g, "");
36345
+ const wordRe = new RegExp(
36346
+ `\\b${idLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
36347
+ "i"
36348
+ );
36349
+ if (wordRe.test(query)) {
36350
+ matched.push(id);
36351
+ seen.add(id);
36352
+ continue;
36353
+ }
36354
+ if (idCompact.length >= 4 && compactQuery.includes(idCompact)) {
36355
+ matched.push(id);
36356
+ seen.add(id);
36357
+ }
36358
+ }
36359
+ const agentMention = lowerQuery.match(/\bagent\s+([a-z0-9_-]{3,40})/i);
36360
+ const flagged = matched.length === 0 && agentMention !== null && agentMention[1] !== void 0 && !records.some((r) => r.agent_id.toLowerCase() === agentMention[1]?.toLowerCase());
36361
+ return { matched, flagged };
36362
+ }
36363
+ function escapeRegex(s) {
36364
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36365
+ }
36366
+ function extractEventTypes(query, enumValues) {
36367
+ const lower = query.toLowerCase();
36368
+ const matched = [];
36369
+ const seen = /* @__PURE__ */ new Set();
36370
+ for (const ev of enumValues) {
36371
+ if (seen.has(ev)) continue;
36372
+ const re = new RegExp(`\\b${escapeRegex(ev)}\\b`, "i");
36373
+ if (re.test(query)) {
36374
+ matched.push(ev);
36375
+ seen.add(ev);
36376
+ }
36377
+ }
36378
+ for (const syn of EVENT_SYNONYMS) {
36379
+ const re = new RegExp(
36380
+ `\\b${syn.phrase.split(/\s+/).map(escapeRegex).join("\\s+")}\\b`,
36381
+ "i"
36382
+ );
36383
+ if (re.test(query)) {
36384
+ for (const c of syn.canonical) {
36385
+ if (seen.has(c)) continue;
36386
+ if (!enumValues.includes(c)) continue;
36387
+ matched.push(c);
36388
+ seen.add(c);
36389
+ }
36390
+ }
36391
+ }
36392
+ const globMatches = lower.match(/\b([a-z_]+)_\*/g) ?? [];
36393
+ for (const glob of globMatches) {
36394
+ const prefix = glob.slice(0, -2);
36395
+ for (const ev of enumValues) {
36396
+ if (seen.has(ev)) continue;
36397
+ if (ev.startsWith(prefix)) {
36398
+ matched.push(ev);
36399
+ seen.add(ev);
36400
+ }
36401
+ }
36402
+ }
36403
+ const eventNounMention = /\b(?:event|events|class|classes)\b/i.test(query) && matched.length === 0;
36404
+ return { matched, flagged: eventNounMention };
36405
+ }
36406
+ function deriveIntentPhrase(query, stripTokens) {
36407
+ let out = query;
36408
+ for (const tok of stripTokens) {
36409
+ if (!tok) continue;
36410
+ const re = new RegExp(escapeRegex(tok), "gi");
36411
+ out = out.replace(re, " ");
36412
+ }
36413
+ return out.replace(/\s+/g, " ").trim();
36414
+ }
36415
+ function computeConfidence(parsed) {
36416
+ const dims = [
36417
+ { present: parsed.hasTimeMention, resolved: parsed.timeResolved },
36418
+ { present: parsed.hasAgentMention, resolved: parsed.agentResolved },
36419
+ { present: parsed.hasEventMention, resolved: parsed.eventResolved }
36420
+ ];
36421
+ const present = dims.filter((d) => d.present);
36422
+ let base;
36423
+ if (present.length === 0) {
36424
+ base = parsed.intentEmpty ? 0 : 0.3;
36425
+ } else {
36426
+ const resolved = present.filter((d) => d.resolved).length;
36427
+ base = resolved / present.length;
36428
+ }
36429
+ const adjusted = base - 0.15 * parsed.ambiguityCount;
36430
+ if (adjusted < 0) return 0;
36431
+ if (adjusted > 1) return 1;
36432
+ return adjusted;
36433
+ }
36434
+ function parseQuery(query, opts) {
36435
+ const now = opts?.now ?? /* @__PURE__ */ new Date();
36436
+ const enumValues = opts?.eventClassEnum ?? CANONICAL_AUDIT_EVENT_CLASSES;
36437
+ const original = query ?? "";
36438
+ const trimmed = original.trim();
36439
+ if (trimmed.length === 0) {
36440
+ return {
36441
+ time_range: null,
36442
+ agent_names: [],
36443
+ event_types: [],
36444
+ intent_phrase: "",
36445
+ ambiguity_flags: ["no_signal_extracted"],
36446
+ parse_confidence: 0
36447
+ };
36448
+ }
36449
+ const ambiguity_flags = /* @__PURE__ */ new Set();
36450
+ const timeMatch = resolveTimeRange(trimmed, now);
36451
+ const hasTimeMention = TIME_MENTION_PROBE.test(trimmed);
36452
+ if (hasTimeMention && !timeMatch) {
36453
+ ambiguity_flags.add("unknown_time_token");
36454
+ }
36455
+ const agentResult = extractAgentNames(trimmed, opts?.registry);
36456
+ if (agentResult.flagged) {
36457
+ ambiguity_flags.add("unknown_agent_token");
36458
+ }
36459
+ const hasAgentMention = AGENT_MENTION_PROBE.test(trimmed);
36460
+ const eventResult = extractEventTypes(trimmed, enumValues);
36461
+ const hasEventMention = EVENT_MENTION_PROBE.test(trimmed);
36462
+ if (eventResult.flagged) {
36463
+ ambiguity_flags.add("unknown_event_token");
36464
+ }
36465
+ const stripTokens = [];
36466
+ if (timeMatch) stripTokens.push(timeMatch.matchedSubstring);
36467
+ for (const name of agentResult.matched) stripTokens.push(name);
36468
+ for (const ev of eventResult.matched) {
36469
+ if (trimmed.toLowerCase().includes(ev.toLowerCase())) {
36470
+ stripTokens.push(ev);
36471
+ }
36472
+ }
36473
+ const intent_phrase = deriveIntentPhrase(trimmed, stripTokens);
36474
+ const parse_confidence = computeConfidence({
36475
+ hasTimeMention,
36476
+ timeResolved: timeMatch !== null,
36477
+ hasAgentMention,
36478
+ agentResolved: agentResult.matched.length > 0,
36479
+ hasEventMention,
36480
+ eventResolved: eventResult.matched.length > 0,
36481
+ intentEmpty: intent_phrase.length === 0,
36482
+ ambiguityCount: ambiguity_flags.size
36483
+ });
36484
+ if (timeMatch === null && agentResult.matched.length === 0 && eventResult.matched.length === 0 && intent_phrase.length === 0) {
36485
+ ambiguity_flags.add("no_signal_extracted");
36486
+ }
36487
+ return {
36488
+ time_range: timeMatch ? timeMatch.range : null,
36489
+ agent_names: agentResult.matched,
36490
+ event_types: eventResult.matched,
36491
+ intent_phrase,
36492
+ ambiguity_flags: Array.from(ambiguity_flags),
36493
+ parse_confidence
36494
+ };
36495
+ }
36496
+ function isLowConfidence(parsed) {
36497
+ return parsed.parse_confidence < LLM_ASSIST_THRESHOLD;
36498
+ }
36499
+ async function parseQueryWithLlmAssist(query, llmAssist, opts) {
36500
+ const parsed = parseQuery(query, opts);
36501
+ if (!llmAssist || !isLowConfidence(parsed)) return parsed;
36502
+ let completion;
36503
+ try {
36504
+ completion = await llmAssist(query, parsed);
36505
+ } catch {
36506
+ return parsed;
36507
+ }
36508
+ if (!completion || typeof completion !== "object") return parsed;
36509
+ const merged = { ...parsed };
36510
+ if (parsed.time_range === null && completion.time_range) {
36511
+ merged.time_range = completion.time_range;
36512
+ }
36513
+ if (parsed.agent_names.length === 0 && Array.isArray(completion.agent_names)) {
36514
+ merged.agent_names = completion.agent_names.filter(
36515
+ (s) => typeof s === "string" && s.length > 0
36516
+ );
36517
+ }
36518
+ if (parsed.event_types.length === 0 && Array.isArray(completion.event_types)) {
36519
+ const allowed = new Set(opts?.eventClassEnum ?? CANONICAL_AUDIT_EVENT_CLASSES);
36520
+ merged.event_types = completion.event_types.filter(
36521
+ (s) => typeof s === "string" && allowed.has(s)
36522
+ );
36523
+ }
36524
+ merged.parse_confidence = Math.max(
36525
+ parsed.parse_confidence,
36526
+ computeConfidence({
36527
+ hasTimeMention: TIME_MENTION_PROBE.test(query),
36528
+ timeResolved: merged.time_range !== null,
36529
+ hasAgentMention: AGENT_MENTION_PROBE.test(query),
36530
+ agentResolved: merged.agent_names.length > 0,
36531
+ hasEventMention: EVENT_MENTION_PROBE.test(query),
36532
+ eventResolved: merged.event_types.length > 0,
36533
+ intentEmpty: merged.intent_phrase.length === 0,
36534
+ ambiguityCount: merged.ambiguity_flags.length
36535
+ })
36536
+ );
36537
+ return merged;
36538
+ }
36539
+ function auditSafeSummary(parsed) {
36540
+ return {
36541
+ time_range: parsed.time_range ? {
36542
+ start_iso: parsed.time_range.start.toISOString(),
36543
+ end_iso: parsed.time_range.end.toISOString(),
36544
+ ...parsed.time_range.relative_label !== void 0 ? { relative_label: parsed.time_range.relative_label } : {}
36545
+ } : null,
36546
+ agent_names: [...parsed.agent_names],
36547
+ event_types: [...parsed.event_types],
36548
+ ambiguity_flags: [...parsed.ambiguity_flags],
36549
+ parse_confidence: parsed.parse_confidence
36550
+ };
36551
+ }
36552
+ var CANONICAL_AUDIT_EVENT_CLASSES, EVENT_SYNONYMS, MS_PER_HOUR, MS_PER_DAY, NUMBER_WORDS, TIME_MENTION_PROBE, AGENT_MENTION_PROBE, EVENT_MENTION_PROBE, LLM_ASSIST_THRESHOLD;
36553
+ var init_concierge_query_grammar = __esm({
36554
+ "src/chat/concierge-query-grammar.ts"() {
36555
+ init_constants4();
36556
+ init_operator_chat_audit_events();
36557
+ CANONICAL_AUDIT_EVENT_CLASSES = [
36558
+ // Lifecycle / policy
36559
+ "policy_change",
36560
+ "approval_request",
36561
+ "audit_truncate",
36562
+ "lockdown",
36563
+ "unwrap",
36564
+ // Exit bundle (Tier 1)
36565
+ "exit_bundle_export",
36566
+ "exit_bundle_import_activate",
36567
+ "exit_bundle_rekey",
36568
+ // Cross-harness approval aggregator
36569
+ "cross_harness_approval_aggregated",
36570
+ "cross_harness_approval_resolved",
36571
+ "cross_harness_approval_deduped",
36572
+ "cross_harness_approval_payload_decrypted",
36573
+ "cross_harness_approval_audit_trail_viewed",
36574
+ "cross_harness_approval_replayed",
36575
+ // Composition (full set from constants.ts)
36576
+ ...COMPOSITION_EVENT_TYPES,
36577
+ // Operator chat / concierge (full set from OPERATOR_CHAT_OPS)
36578
+ OPERATOR_CHAT_OPS.CONCIERGE_CHAT,
36579
+ OPERATOR_CHAT_OPS.AGENT_INSPECT_PANEL_OPENED,
36580
+ OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ,
36581
+ OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED,
36582
+ OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED,
36583
+ OPERATOR_CHAT_OPS.CONCIERGE_CONTEXT_FETCHER_FAILED,
36584
+ // Bridge / commitment
36585
+ "bridge_commit",
36586
+ "bridge_verify",
36587
+ "bridge_attest",
36588
+ "proof_commitment",
36589
+ "proof_reveal",
36590
+ // Reputation
36591
+ "reputation_export",
36592
+ "reputation_import",
36593
+ "reputation_publish",
36594
+ "reputation_record",
36595
+ "reputation_query"
36596
+ ];
36597
+ EVENT_SYNONYMS = [
36598
+ { phrase: "approvals", canonical: ["approval_request", "cross_harness_approval_aggregated", "cross_harness_approval_resolved"] },
36599
+ { phrase: "approval", canonical: ["approval_request"] },
36600
+ { phrase: "policy changes", canonical: ["policy_change"] },
36601
+ { phrase: "policy change", canonical: ["policy_change"] },
36602
+ { phrase: "policy edits", canonical: ["policy_change"] },
36603
+ { phrase: "lockdowns", canonical: ["lockdown"] },
36604
+ { phrase: "exit bundles", canonical: ["exit_bundle_export", "exit_bundle_import_activate"] },
36605
+ { phrase: "exit bundle", canonical: ["exit_bundle_export"] },
36606
+ { phrase: "audit truncations", canonical: ["audit_truncate"] },
36607
+ { phrase: "audit truncation", canonical: ["audit_truncate"] },
36608
+ { phrase: "compositions", canonical: ["composition_receipt_packed", "composition_receipt_verified"] },
36609
+ { phrase: "receipts", canonical: ["composition_receipt_packed", "composition_receipt_verified"] },
36610
+ { phrase: "receipt verifications", canonical: ["composition_receipt_verified"] },
36611
+ { phrase: "concierge chats", canonical: [OPERATOR_CHAT_OPS.CONCIERGE_CHAT] },
36612
+ { phrase: "cross harness approvals", canonical: ["cross_harness_approval_aggregated", "cross_harness_approval_resolved"] }
36613
+ ];
36614
+ MS_PER_HOUR = 60 * 60 * 1e3;
36615
+ MS_PER_DAY = 24 * MS_PER_HOUR;
36616
+ NUMBER_WORDS = {
36617
+ a: 1,
36618
+ an: 1,
36619
+ one: 1,
36620
+ two: 2,
36621
+ three: 3,
36622
+ four: 4,
36623
+ five: 5,
36624
+ six: 6,
36625
+ seven: 7,
36626
+ eight: 8,
36627
+ nine: 9,
36628
+ ten: 10,
36629
+ twelve: 12,
36630
+ twentyfour: 24
36631
+ };
36632
+ 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;
36633
+ AGENT_MENTION_PROBE = /\bagent[s]?\b/i;
36634
+ EVENT_MENTION_PROBE = /\b(event|events|class|classes|approvals?|policy)\b/i;
36635
+ LLM_ASSIST_THRESHOLD = 0.5;
36636
+ }
36637
+ });
36638
+
36639
+ // src/chat/agent-context-cache.ts
34040
36640
  function approxTokenLen2(text) {
34041
36641
  return Math.ceil(text.length / 4);
34042
36642
  }
36643
+ function formatSnapshotLine(snapshot) {
36644
+ const flagLabel = snapshot.state_flags.join("+") || "no_flags";
36645
+ const work = snapshot.current_work_summary ? `, last: ${snapshot.current_work_summary}` : "";
36646
+ const verascore = snapshot.recent_verascore_delta_24h !== null ? `, verascore \u0394${snapshot.recent_verascore_delta_24h.toFixed(2)}` : "";
36647
+ return `- ${snapshot.agent_name} (template: ${snapshot.template}): ${flagLabel}, ${snapshot.recent_audit_count_24h} audit/24h, ${snapshot.recent_concordia_receipts_count_24h} receipts${verascore}${work}`;
36648
+ }
36649
+ function urgencyRank(snapshot) {
36650
+ for (let i = 0; i < STATE_FLAG_ORDER.length; i++) {
36651
+ if (snapshot.state_flags.includes(STATE_FLAG_ORDER[i])) {
36652
+ return i;
36653
+ }
36654
+ }
36655
+ return STATE_FLAG_ORDER.length;
36656
+ }
36657
+ function formatCurrentAgentStateSection(snapshots, opts) {
36658
+ if (snapshots.length === 0) return "";
36659
+ const budget = opts?.maxTokens ?? DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
36660
+ const sorted = [...snapshots].sort(
36661
+ (a, b) => urgencyRank(a) - urgencyRank(b)
36662
+ );
36663
+ const headerTokens = approxTokenLen2(`${SECTION_HEADER}
36664
+ `);
36665
+ const sepTokens = approxTokenLen2("\n");
36666
+ let runningTokens = headerTokens;
36667
+ const kept = [];
36668
+ for (const snap of sorted) {
36669
+ const line = formatSnapshotLine(snap);
36670
+ const tokens = approxTokenLen2(line) + (kept.length > 0 ? sepTokens : 0);
36671
+ if (kept.length === 0) {
36672
+ kept.push(line);
36673
+ runningTokens += tokens;
36674
+ continue;
36675
+ }
36676
+ if (runningTokens + tokens > budget) break;
36677
+ kept.push(line);
36678
+ runningTokens += tokens;
36679
+ }
36680
+ return `${SECTION_HEADER}
36681
+ ${kept.join("\n")}`;
36682
+ }
36683
+ function generateProactiveStarter(snapshots) {
36684
+ if (snapshots.length === 0) return null;
36685
+ const stuck = snapshots.filter((s) => s.state_flags.includes("stuck"));
36686
+ if (stuck.length > 0) {
36687
+ const first = stuck[0];
36688
+ if (first === void 0) return null;
36689
+ const last = first.current_work_summary ? ` (last: ${first.current_work_summary})` : "";
36690
+ return {
36691
+ text: `Your ${first.agent_name} agent looks stuck${last}. Should I check its session state?`,
36692
+ trigger: "stuck_agent",
36693
+ triggered_agents_count: stuck.length
36694
+ };
36695
+ }
36696
+ const pending = snapshots.filter(
36697
+ (s) => s.state_flags.includes("has_pending_approvals")
36698
+ );
36699
+ if (pending.length > 0) {
36700
+ const names = pending.slice(0, 3).map((s) => s.agent_name).join(", ");
36701
+ return {
36702
+ text: `You have pending approvals across ${names}. Want to walk through them?`,
36703
+ trigger: "pending_approvals",
36704
+ triggered_agents_count: pending.length
36705
+ };
36706
+ }
36707
+ const findings = snapshots.filter(
36708
+ (s) => s.state_flags.includes("has_open_findings")
36709
+ );
36710
+ if (findings.length > 0) {
36711
+ return {
36712
+ text: `Sentinel has open findings on ${findings.length} ${findings.length === 1 ? "agent" : "agents"}. Want a summary?`,
36713
+ trigger: "open_findings",
36714
+ triggered_agents_count: findings.length
36715
+ };
36716
+ }
36717
+ return {
36718
+ text: "Your fortress is quiet. Anything you'd like to inspect?",
36719
+ trigger: "all_idle",
36720
+ triggered_agents_count: snapshots.length
36721
+ };
36722
+ }
36723
+ var STATE_FLAG_ORDER, SECTION_HEADER, DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
36724
+ var init_agent_context_cache = __esm({
36725
+ "src/chat/agent-context-cache.ts"() {
36726
+ STATE_FLAG_ORDER = [
36727
+ "stuck",
36728
+ "has_pending_approvals",
36729
+ "has_open_findings",
36730
+ "active",
36731
+ "idle"
36732
+ ];
36733
+ SECTION_HEADER = "## Current agent state";
36734
+ DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET = 400;
36735
+ }
36736
+ });
36737
+ function approxTokenLen3(text) {
36738
+ return Math.ceil(text.length / 4);
36739
+ }
34043
36740
  function makeEventId(prefix) {
34044
36741
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}`;
34045
36742
  }
@@ -34061,7 +36758,7 @@ function formatPriorTurnLine(turn) {
34061
36758
  function hashOf(input) {
34062
36759
  return hashToString(sha256(stringToBytes(input)));
34063
36760
  }
34064
- var DEFAULT_CONCIERGE_MAX_TOKENS, DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS, DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS, DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET, DEFAULT_CONCIERGE_SESSION_TTL_MS, DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
36761
+ var DEFAULT_CONCIERGE_MAX_TOKENS, DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS, DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS, DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET, DEFAULT_CONCIERGE_SESSION_TTL_MS, DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET, DEFAULT_CONCIERGE_AGENT_STATE_BUDGET, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
34065
36762
  var init_operator_chat_service = __esm({
34066
36763
  "src/chat/operator-chat-service.ts"() {
34067
36764
  init_hashing();
@@ -34069,12 +36766,15 @@ var init_operator_chat_service = __esm({
34069
36766
  init_operator_chat_audit_events();
34070
36767
  init_operator_chat_types();
34071
36768
  init_concierge_context_router();
36769
+ init_concierge_query_grammar();
36770
+ init_agent_context_cache();
34072
36771
  DEFAULT_CONCIERGE_MAX_TOKENS = 512;
34073
36772
  DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
34074
36773
  DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
34075
36774
  DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
34076
36775
  DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
34077
36776
  DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET = 2e3;
36777
+ DEFAULT_CONCIERGE_AGENT_STATE_BUDGET = 400;
34078
36778
  SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
34079
36779
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
34080
36780
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -34119,6 +36819,17 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34119
36819
  contextFetchers;
34120
36820
  contextLlmAssist;
34121
36821
  dynamicContextBudget;
36822
+ agentRegistry;
36823
+ grammarLlmAssist;
36824
+ agentContextCache;
36825
+ agentStateBudget;
36826
+ /**
36827
+ * Per-thread guard so the proactive starter fires at most once per
36828
+ * fresh thread. Tracks the thread_id the starter was last offered
36829
+ * for; subsequent `getProactiveStarter()` calls within the same
36830
+ * thread return null instead of re-emitting.
36831
+ */
36832
+ starterOfferedForThreadId;
34122
36833
  /**
34123
36834
  * In-memory thread_id assigned to the active concierge session.
34124
36835
  * The first sendConcierge call after construction allocates a fresh
@@ -34157,6 +36868,16 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34157
36868
  this.contextLlmAssist = deps.conciergeContextLlmAssist;
34158
36869
  }
34159
36870
  this.dynamicContextBudget = deps.conciergeDynamicContextBudget !== void 0 && deps.conciergeDynamicContextBudget > 0 ? deps.conciergeDynamicContextBudget : DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET;
36871
+ if (deps.conciergeAgentRegistry) {
36872
+ this.agentRegistry = deps.conciergeAgentRegistry;
36873
+ }
36874
+ if (deps.conciergeGrammarLlmAssist) {
36875
+ this.grammarLlmAssist = deps.conciergeGrammarLlmAssist;
36876
+ }
36877
+ if (deps.conciergeAgentContextCache) {
36878
+ this.agentContextCache = deps.conciergeAgentContextCache;
36879
+ }
36880
+ this.agentStateBudget = deps.conciergeAgentStateBudget !== void 0 && deps.conciergeAgentStateBudget > 0 ? deps.conciergeAgentStateBudget : DEFAULT_CONCIERGE_AGENT_STATE_BUDGET;
34160
36881
  }
34161
36882
  // ── Concierge ─────────────────────────────────────────────────────────
34162
36883
  /**
@@ -34178,6 +36899,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34178
36899
  const nowMs = this.clock();
34179
36900
  if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
34180
36901
  this.activeMemoryThreadId = void 0;
36902
+ this.starterOfferedForThreadId = void 0;
34181
36903
  }
34182
36904
  const operatorMessage = {
34183
36905
  message_id: randomUUID(),
@@ -34215,6 +36937,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34215
36937
  await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
34216
36938
  });
34217
36939
  }
36940
+ const parsedGrammar = await this.runGrammarParse(filterResult.filtered);
36941
+ const agentSnapshots = this.agentContextCache ? this.agentContextCache.read() : [];
36942
+ const agentStateSection = this.agentContextCache ? formatCurrentAgentStateSection(agentSnapshots, {
36943
+ maxTokens: this.agentStateBudget
36944
+ }) : "";
36945
+ const renderedAgentCount = agentStateSection ? agentSnapshots.length : 0;
34218
36946
  const start = Date.now();
34219
36947
  let conciergeBody;
34220
36948
  let servedBy = "disabled";
@@ -34233,12 +36961,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34233
36961
  outcome = "substrate_disabled";
34234
36962
  } else {
34235
36963
  const dynamicResult = await this.runDynamicContextFold(
34236
- filterResult.filtered
36964
+ filterResult.filtered,
36965
+ parsedGrammar
34237
36966
  );
34238
36967
  dynamicCategoriesIncluded = dynamicResult.categoriesIncluded;
34239
36968
  const context = await this.assembleConciergeContext(
34240
36969
  priorTurns,
34241
- dynamicResult.section
36970
+ dynamicResult.section,
36971
+ agentStateSection
34242
36972
  );
34243
36973
  const response = await this.substrateSelector.invokeSummarize(
34244
36974
  "concierge",
@@ -34304,7 +37034,9 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34304
37034
  ...this.memory ? {
34305
37035
  prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
34306
37036
  } : {},
34307
- ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {}
37037
+ ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {},
37038
+ parsed_grammar: auditSafeSummary(parsedGrammar),
37039
+ ...this.agentContextCache !== void 0 ? { agent_context_snapshot_count: renderedAgentCount } : {}
34308
37040
  };
34309
37041
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
34310
37042
  return {
@@ -34415,6 +37147,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34415
37147
  if (!removed) return false;
34416
37148
  if (this.activeMemoryThreadId === threadId) {
34417
37149
  this.activeMemoryThreadId = void 0;
37150
+ this.starterOfferedForThreadId = void 0;
34418
37151
  }
34419
37152
  const payload = {
34420
37153
  version: "1.2",
@@ -34433,9 +37166,65 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34433
37166
  * Reset the active session memory thread. Subsequent sendConcierge
34434
37167
  * calls allocate a fresh thread_id. Surfaced for tests + future "new
34435
37168
  * conversation" affordance; not currently called by the dashboard.
37169
+ *
37170
+ * Tau-5: also clears the proactive-starter guard so the next
37171
+ * `getProactiveStarter()` call against the freshly-allocated thread
37172
+ * is eligible to fire.
34436
37173
  */
34437
37174
  resetConciergeMemoryThread() {
34438
37175
  this.activeMemoryThreadId = void 0;
37176
+ this.starterOfferedForThreadId = void 0;
37177
+ }
37178
+ /**
37179
+ * WP-V1.3-9 Tau-5: surface a proactive starter for the current
37180
+ * concierge session. Intended to be called by the dashboard UI when
37181
+ * the operator opens the chat surface, before any operator typing.
37182
+ *
37183
+ * Returns null when:
37184
+ * - No agent-context cache is wired (Tau-5 disabled).
37185
+ * - No concierge memory store is wired (no thread_id namespace).
37186
+ * - The cache snapshot has no signal (empty fortress).
37187
+ * - A starter has already been offered for the active thread (the
37188
+ * guard ensures one starter per fresh thread).
37189
+ *
37190
+ * Side effects:
37191
+ * - Allocates a fresh thread_id if none is active.
37192
+ * - Emits the `operator_concierge_proactive_suggestion_offered`
37193
+ * audit event with the trigger class + triggered_agents_count.
37194
+ * - Records the offered thread_id so the next call within the same
37195
+ * thread is a no-op.
37196
+ *
37197
+ * The returned starter's `text` is operator-visible copy; the
37198
+ * dashboard renders it as a system-message-style starter the
37199
+ * operator can accept (clicks/types follow-up) or dismiss (types a
37200
+ * new query).
37201
+ */
37202
+ getProactiveStarter() {
37203
+ if (!this.agentContextCache) return null;
37204
+ if (!this.memory) return null;
37205
+ const threadId = this.ensureActiveMemoryThread();
37206
+ if (this.starterOfferedForThreadId === threadId) return null;
37207
+ const snapshots = this.agentContextCache.read();
37208
+ const starter = generateProactiveStarter(snapshots);
37209
+ if (!starter) return null;
37210
+ const payload = {
37211
+ version: "1.2",
37212
+ event_id: makeEventId("conc-starter"),
37213
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
37214
+ identity_id: this.identityId,
37215
+ kind: "operator_concierge_proactive_suggestion_offered",
37216
+ surface: "concierge",
37217
+ thread_id: threadId,
37218
+ trigger: starter.trigger,
37219
+ triggered_agents_count: starter.triggered_agents_count
37220
+ };
37221
+ this.emit(
37222
+ OPERATOR_CHAT_OPS.CONCIERGE_PROACTIVE_SUGGESTION_OFFERED,
37223
+ payload,
37224
+ "success"
37225
+ );
37226
+ this.starterOfferedForThreadId = threadId;
37227
+ return starter;
34439
37228
  }
34440
37229
  ensureActiveMemoryThread() {
34441
37230
  if (!this.activeMemoryThreadId) {
@@ -34480,7 +37269,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34480
37269
  * if available; the v1.2 selector does not expose one, so structured
34481
37270
  * serialization is the canonical path for v1.3.
34482
37271
  */
34483
- async assembleConciergeContext(priorTurns = [], dynamicSection = "") {
37272
+ async assembleConciergeContext(priorTurns = [], dynamicSection = "", agentStateSection = "") {
34484
37273
  const ref = `## Sanctuary reference
34485
37274
  ${SANCTUARY_DOMAIN_REFERENCE}`;
34486
37275
  const priorSection = this.formatPriorTurnsSection(priorTurns);
@@ -34488,6 +37277,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
34488
37277
  return [
34489
37278
  ref,
34490
37279
  ...dynamicSection ? [dynamicSection] : [],
37280
+ ...agentStateSection ? [agentStateSection] : [],
34491
37281
  ...priorSection ? [priorSection] : [],
34492
37282
  "## Recent activity\n(no providers wired)",
34493
37283
  "## Wrapped agents\n(no providers wired)",
@@ -34502,6 +37292,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
34502
37292
  return [
34503
37293
  ref,
34504
37294
  ...dynamicSection ? [dynamicSection] : [],
37295
+ ...agentStateSection ? [agentStateSection] : [],
34505
37296
  ...priorSection ? [priorSection] : [],
34506
37297
  `## Recent activity
34507
37298
  ${activity}`,
@@ -34519,8 +37310,12 @@ ${inbox}`
34519
37310
  * proceeds with no fold. Returns the rendered section + the list of
34520
37311
  * categories whose data made it into the section (used for the
34521
37312
  * round-trip audit emission).
37313
+ *
37314
+ * Tau-4: receives the pre-parsed `ParsedQuery` and forwards it as the
37315
+ * `parsed` opt to `foldContext`, so fetchers see the structured
37316
+ * `FetcherHints` derived from it.
34522
37317
  */
34523
- async runDynamicContextFold(query) {
37318
+ async runDynamicContextFold(query, parsedGrammar) {
34524
37319
  if (!this.contextFetchers) {
34525
37320
  return { section: "", categoriesIncluded: [] };
34526
37321
  }
@@ -34529,10 +37324,24 @@ ${inbox}`
34529
37324
  ...this.contextLlmAssist ? { llmAssistClassify: this.contextLlmAssist } : {},
34530
37325
  onFetcherFailure: (category, error) => {
34531
37326
  this.emitContextFetcherFailed(category, classifyFetcherError(error));
34532
- }
37327
+ },
37328
+ parsed: parsedGrammar
34533
37329
  });
34534
37330
  return result;
34535
37331
  }
37332
+ /**
37333
+ * WP-V1.3-9 Tau-4: parse the (PII-filtered) operator query into a
37334
+ * `ParsedQuery`. Routes through the LLM-assist completion hook when
37335
+ * configured and the rule-based parse is below
37336
+ * `LLM_ASSIST_THRESHOLD`. Always returns a parse object (never
37337
+ * throws) so the audit emission can carry the result unconditionally.
37338
+ */
37339
+ async runGrammarParse(query) {
37340
+ return parseQueryWithLlmAssist(query, this.grammarLlmAssist, {
37341
+ ...this.agentRegistry !== void 0 ? { registry: this.agentRegistry } : {},
37342
+ eventClassEnum: CANONICAL_AUDIT_EVENT_CLASSES
37343
+ });
37344
+ }
34536
37345
  /**
34537
37346
  * Emit the WP-V1.3-9 Tau-3 fetcher-failure audit event. Pulled out
34538
37347
  * of the fold path so the dynamic-context handler stays readable.
@@ -34566,14 +37375,14 @@ ${inbox}`
34566
37375
  if (turns.length === 0) return "";
34567
37376
  const HEADER = "## Prior conversation";
34568
37377
  const lines = turns.map(formatPriorTurnLine);
34569
- const headerTokens = approxTokenLen2(`${HEADER}
37378
+ const headerTokens = approxTokenLen3(`${HEADER}
34570
37379
  `);
34571
- const sepTokens = approxTokenLen2("\n");
37380
+ const sepTokens = approxTokenLen3("\n");
34572
37381
  let runningTokens = headerTokens;
34573
37382
  let runningLines = [];
34574
37383
  for (let i = lines.length - 1; i >= 0; i--) {
34575
37384
  const line = lines[i];
34576
- const tokens = approxTokenLen2(line) + (runningLines.length > 0 ? sepTokens : 0);
37385
+ const tokens = approxTokenLen3(line) + (runningLines.length > 0 ? sepTokens : 0);
34577
37386
  if (runningTokens + tokens > this.historyTokenBudget) break;
34578
37387
  runningTokens += tokens;
34579
37388
  runningLines.push(line);
@@ -34601,7 +37410,7 @@ ${runningLines.join("\n")}`;
34601
37410
  function chatStorageKey(surface, threadKey) {
34602
37411
  return `${surface}.${threadKey}`;
34603
37412
  }
34604
- var OPERATOR_CHAT_NAMESPACE, HKDF_INFO2, OperatorChatStore;
37413
+ var OPERATOR_CHAT_NAMESPACE, HKDF_INFO3, OperatorChatStore;
34605
37414
  var init_operator_chat_store = __esm({
34606
37415
  "src/chat/operator-chat-store.ts"() {
34607
37416
  init_encryption();
@@ -34609,13 +37418,13 @@ var init_operator_chat_store = __esm({
34609
37418
  init_encoding();
34610
37419
  init_operator_chat_types();
34611
37420
  OPERATOR_CHAT_NAMESPACE = "_chat";
34612
- HKDF_INFO2 = "operator-chat-store-v1";
37421
+ HKDF_INFO3 = "operator-chat-store-v1";
34613
37422
  OperatorChatStore = class {
34614
37423
  storage;
34615
37424
  encryptionKey;
34616
37425
  constructor(storage, masterKey) {
34617
37426
  this.storage = storage;
34618
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
37427
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
34619
37428
  }
34620
37429
  /**
34621
37430
  * Load a thread. Returns null if no record exists or if the on-disk
@@ -34700,7 +37509,7 @@ var init_operator_chat_store = __esm({
34700
37509
  function bundleKey(threadId) {
34701
37510
  return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
34702
37511
  }
34703
- function stripKeyPrefix2(key) {
37512
+ function stripKeyPrefix3(key) {
34704
37513
  if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
34705
37514
  return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
34706
37515
  }
@@ -34711,7 +37520,7 @@ function lastTurnId(bundle) {
34711
37520
  }
34712
37521
  return max;
34713
37522
  }
34714
- var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO3, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
37523
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO4, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
34715
37524
  var init_concierge_memory_store = __esm({
34716
37525
  "src/chat/concierge-memory-store.ts"() {
34717
37526
  init_encryption();
@@ -34719,7 +37528,7 @@ var init_concierge_memory_store = __esm({
34719
37528
  init_encoding();
34720
37529
  CONCIERGE_MEMORY_NAMESPACE = "_chat";
34721
37530
  CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
34722
- HKDF_INFO3 = "concierge-memory-store-v1";
37531
+ HKDF_INFO4 = "concierge-memory-store-v1";
34723
37532
  DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
34724
37533
  MAX_BUNDLE_BYTES3 = 4 * 1024 * 1024;
34725
37534
  ConciergeMemoryStore = class {
@@ -34730,7 +37539,7 @@ var init_concierge_memory_store = __esm({
34730
37539
  locks;
34731
37540
  constructor(opts) {
34732
37541
  this.storage = opts.storage;
34733
- this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO3);
37542
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO4);
34734
37543
  this.fortressId = opts.fortressId;
34735
37544
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
34736
37545
  this.locks = /* @__PURE__ */ new Map();
@@ -34856,7 +37665,7 @@ var init_concierge_memory_store = __esm({
34856
37665
  );
34857
37666
  const summaries = [];
34858
37667
  for (const meta of entries) {
34859
- const threadId = stripKeyPrefix2(meta.key);
37668
+ const threadId = stripKeyPrefix3(meta.key);
34860
37669
  if (threadId === null) continue;
34861
37670
  const bundle = await this.loadBundle(threadId);
34862
37671
  if (!bundle || bundle.turns.length === 0) continue;
@@ -34909,7 +37718,7 @@ var init_concierge_memory_store = __esm({
34909
37718
  );
34910
37719
  let pruned = 0;
34911
37720
  for (const meta of entries) {
34912
- const threadId = stripKeyPrefix2(meta.key);
37721
+ const threadId = stripKeyPrefix3(meta.key);
34913
37722
  if (threadId === null) continue;
34914
37723
  pruned += await this.withLock(threadId, async () => {
34915
37724
  const bundle = await this.loadBundle(threadId);
@@ -35379,7 +38188,7 @@ var init_defaults = __esm({
35379
38188
  });
35380
38189
 
35381
38190
  // src/intelligence/policy-store.ts
35382
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO4, IntelligenceConfigStore;
38191
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO5, IntelligenceConfigStore;
35383
38192
  var init_policy_store = __esm({
35384
38193
  "src/intelligence/policy-store.ts"() {
35385
38194
  init_encryption();
@@ -35388,13 +38197,13 @@ var init_policy_store = __esm({
35388
38197
  init_defaults();
35389
38198
  INTELLIGENCE_NAMESPACE = "_intelligence";
35390
38199
  SUBSTRATE_CONFIG_KEY = "substrate-config";
35391
- HKDF_INFO4 = "intelligence-substrate-config";
38200
+ HKDF_INFO5 = "intelligence-substrate-config";
35392
38201
  IntelligenceConfigStore = class {
35393
38202
  storage;
35394
38203
  encryptionKey;
35395
38204
  constructor(storage, masterKey) {
35396
38205
  this.storage = storage;
35397
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO4);
38206
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO5);
35398
38207
  }
35399
38208
  /**
35400
38209
  * Load the operator's substrate config from disk. Returns the config
@@ -37044,7 +39853,7 @@ var init_memory = __esm({
37044
39853
 
37045
39854
  // src/contracts/v1.1/constants.ts
37046
39855
  var SIGNATURE_SCHEME_V12, EXIT_BUNDLE_MANIFEST_VERSION, EXIT_BUNDLE_ARTIFACT_KINDS;
37047
- var init_constants4 = __esm({
39856
+ var init_constants5 = __esm({
37048
39857
  "src/contracts/v1.1/constants.ts"() {
37049
39858
  SIGNATURE_SCHEME_V12 = "ed25519-v1";
37050
39859
  EXIT_BUNDLE_MANIFEST_VERSION = "SANCTUARY_EXIT_BUNDLE_V1";
@@ -37462,7 +40271,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
37462
40271
  var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
37463
40272
  var init_verifier2 = __esm({
37464
40273
  "src/exit/verifier.ts"() {
37465
- init_constants4();
40274
+ init_constants5();
37466
40275
  init_exit_bundle_manifest();
37467
40276
  init_encoding();
37468
40277
  init_hashing();
@@ -38274,7 +41083,7 @@ var init_bundle = __esm({
38274
41083
  "src/exit/bundle.ts"() {
38275
41084
  init_state_store();
38276
41085
  init_config();
38277
- init_constants4();
41086
+ init_constants5();
38278
41087
  init_canonical_json();
38279
41088
  init_hashing();
38280
41089
  init_encoding();
@@ -39345,6 +42154,38 @@ ${err.message}
39345
42154
  if (dashboard) {
39346
42155
  dashboard.setApprovalAggregator(approvalAggregator);
39347
42156
  }
42157
+ const sentinelFindingStore = new SentinelFindingStore({
42158
+ storage,
42159
+ masterKey,
42160
+ fortressId: fortressIdForAggregator
42161
+ });
42162
+ const sentinelRegistry = new SentinelRegistry();
42163
+ for (const entry of PHI1_BASELINE_CATALOG) {
42164
+ sentinelRegistry.register(entry);
42165
+ }
42166
+ const sentinelDispatcher = new SentinelDispatcher({
42167
+ registry: sentinelRegistry,
42168
+ findingStore: sentinelFindingStore,
42169
+ auditLog,
42170
+ fortressId: fortressIdForAggregator,
42171
+ identityId: aggregatorIdentityId
42172
+ });
42173
+ try {
42174
+ const persistedSubscriptions = await loadSentinelSubscriptions(
42175
+ config.storage_path
42176
+ );
42177
+ for (const sentinelId of persistedSubscriptions) {
42178
+ try {
42179
+ await sentinelDispatcher.subscribeSentinel(sentinelId);
42180
+ } catch {
42181
+ }
42182
+ }
42183
+ } catch {
42184
+ }
42185
+ sentinelDispatcher.start();
42186
+ if (dashboard) {
42187
+ dashboard.setSentinelDispatcher(sentinelDispatcher);
42188
+ }
39348
42189
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
39349
42190
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
39350
42191
  config,
@@ -39538,6 +42379,11 @@ var init_src = __esm({
39538
42379
  init_approval_aggregator();
39539
42380
  init_aggregator_backed_channel();
39540
42381
  init_aggregator_store();
42382
+ init_sentinel_finding_store();
42383
+ init_sentinel_registry();
42384
+ init_sentinel_dispatcher();
42385
+ init_sentinels();
42386
+ init_subscription_store();
39541
42387
  init_tools4();
39542
42388
  init_router();
39543
42389
  init_router();
@@ -44707,6 +47553,232 @@ var init_intelligence = __esm({
44707
47553
  }
44708
47554
  });
44709
47555
 
47556
+ // src/cli/sentinel.ts
47557
+ var sentinel_exports = {};
47558
+ __export(sentinel_exports, {
47559
+ runSentinelCommand: () => runSentinelCommand
47560
+ });
47561
+ async function runSentinelCommand(args) {
47562
+ const out = args.out ?? process.stdout;
47563
+ const err = args.err ?? process.stderr;
47564
+ const [sub, ...rest] = args.argv;
47565
+ if (!sub || sub === "--help" || sub === "-h") {
47566
+ printUsage6(out);
47567
+ return 0;
47568
+ }
47569
+ try {
47570
+ switch (sub) {
47571
+ case "list":
47572
+ return cmdList4(out);
47573
+ case "list-subscribed":
47574
+ return await cmdListSubscribed(rest, { out, err, args });
47575
+ case "subscribe":
47576
+ return await cmdSubscribe(rest, { out, err, args });
47577
+ case "unsubscribe":
47578
+ return await cmdUnsubscribe(rest, { out, err, args });
47579
+ case "findings":
47580
+ return await cmdFindings(rest, { out, err, args });
47581
+ default:
47582
+ err.write(`Unknown subcommand: ${sub}
47583
+ `);
47584
+ printUsage6(err);
47585
+ return 2;
47586
+ }
47587
+ } catch (e) {
47588
+ const msg = e instanceof Error ? e.message : String(e);
47589
+ err.write(`sanctuary sentinel: ${msg}
47590
+ `);
47591
+ return 1;
47592
+ }
47593
+ }
47594
+ function printUsage6(s) {
47595
+ s.write(`Usage: sanctuary sentinel <command> [args]
47596
+
47597
+ list Show the Phi-1 catalog of available
47598
+ sentinels (egress-volume only at v1.3
47599
+ Phi-1; more land in Phi-2 ... Phi-5).
47600
+ list-subscribed Show which sentinels this fortress has
47601
+ opted into. Loads from
47602
+ <storage>/sentinel-subscriptions.json.
47603
+ subscribe <sentinel-id> Opt in. Writes the subscription file.
47604
+ The server picks it up on next boot.
47605
+ unsubscribe <sentinel-id> Opt out.
47606
+ findings [opts] Read recent findings. Decrypts the
47607
+ sentinel findings store (uses the
47608
+ same passphrase as the cocoon master
47609
+ key).
47610
+ --since <iso> Filter observed_at >= iso.
47611
+ --severity <info|warn|alert> Filter by severity.
47612
+ --sentinel-id <id> Filter by emitting sentinel.
47613
+ --agent-id <id> Filter by agent attribution.
47614
+ --limit <n> Cap result count (default 100).
47615
+ `);
47616
+ }
47617
+ function cmdList4(out) {
47618
+ for (const entry of PHI1_BASELINE_CATALOG) {
47619
+ out.write(`${entry.sentinelId}
47620
+ ${entry.description}
47621
+ `);
47622
+ }
47623
+ if (PHI1_BASELINE_CATALOG.length === 0) {
47624
+ out.write("(no sentinels registered)\n");
47625
+ }
47626
+ return 0;
47627
+ }
47628
+ async function cmdListSubscribed(argv, ctx) {
47629
+ const storagePath = await resolveStoragePath2(ctx.args);
47630
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47631
+ if (subscribed.size === 0) {
47632
+ ctx.out.write("(no subscriptions)\n");
47633
+ return 0;
47634
+ }
47635
+ for (const id of [...subscribed].sort()) {
47636
+ ctx.out.write(`${id}
47637
+ `);
47638
+ }
47639
+ return 0;
47640
+ }
47641
+ async function cmdSubscribe(argv, ctx) {
47642
+ const sentinelId = argv[0];
47643
+ if (!sentinelId) {
47644
+ ctx.err.write("subscribe requires a sentinel-id\n");
47645
+ return 2;
47646
+ }
47647
+ const known = PHI1_BASELINE_CATALOG.find(
47648
+ (entry) => entry.sentinelId === sentinelId
47649
+ );
47650
+ if (!known) {
47651
+ ctx.err.write(`Unknown sentinel: ${sentinelId}
47652
+ `);
47653
+ return 2;
47654
+ }
47655
+ const storagePath = await resolveStoragePath2(ctx.args);
47656
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47657
+ if (subscribed.has(sentinelId)) {
47658
+ ctx.out.write(`Already subscribed: ${sentinelId}
47659
+ `);
47660
+ return 0;
47661
+ }
47662
+ subscribed.add(sentinelId);
47663
+ await saveSentinelSubscriptions(storagePath, subscribed);
47664
+ ctx.out.write(
47665
+ `Subscribed: ${sentinelId}
47666
+ Restart Sanctuary or wait for the next dispatcher tick to begin evaluation.
47667
+ `
47668
+ );
47669
+ return 0;
47670
+ }
47671
+ async function cmdUnsubscribe(argv, ctx) {
47672
+ const sentinelId = argv[0];
47673
+ if (!sentinelId) {
47674
+ ctx.err.write("unsubscribe requires a sentinel-id\n");
47675
+ return 2;
47676
+ }
47677
+ const storagePath = await resolveStoragePath2(ctx.args);
47678
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47679
+ if (!subscribed.has(sentinelId)) {
47680
+ ctx.out.write(`Not subscribed: ${sentinelId}
47681
+ `);
47682
+ return 0;
47683
+ }
47684
+ subscribed.delete(sentinelId);
47685
+ await saveSentinelSubscriptions(storagePath, subscribed);
47686
+ ctx.out.write(`Unsubscribed: ${sentinelId}
47687
+ `);
47688
+ return 0;
47689
+ }
47690
+ async function cmdFindings(argv, ctx) {
47691
+ const filters = parseFindingFilters(argv);
47692
+ const storagePath = await resolveStoragePath2(ctx.args);
47693
+ const storage = new FilesystemStorage(`${storagePath}/state`);
47694
+ let passphrase = ctx.args.passphrase ?? process.env["SANCTUARY_PASSPHRASE"];
47695
+ if (!passphrase) {
47696
+ const resolved = await getOrCreatePassphrase();
47697
+ passphrase = resolved.value;
47698
+ }
47699
+ let existingParams;
47700
+ try {
47701
+ const raw = await storage.read("_meta", "key-params");
47702
+ if (raw) existingParams = JSON.parse(bytesToString(raw));
47703
+ } catch {
47704
+ }
47705
+ const { key: masterKey, params } = await deriveMasterKey(
47706
+ passphrase,
47707
+ existingParams
47708
+ );
47709
+ if (!existingParams) {
47710
+ await storage.write(
47711
+ "_meta",
47712
+ "key-params",
47713
+ stringToBytes(JSON.stringify(params))
47714
+ );
47715
+ }
47716
+ const fortressId = fortressIdFromStoragePath(storagePath);
47717
+ const store = new SentinelFindingStore({
47718
+ storage,
47719
+ masterKey,
47720
+ fortressId
47721
+ });
47722
+ const findings = await store.listFindings({
47723
+ limit: filters.limit ?? 100,
47724
+ ...filters.since !== void 0 ? { since: filters.since } : {},
47725
+ ...filters.severity !== void 0 ? { severity: filters.severity } : {},
47726
+ ...filters.sentinelId !== void 0 ? { sentinelId: filters.sentinelId } : {},
47727
+ ...filters.agentId !== void 0 ? { agentId: filters.agentId } : {}
47728
+ });
47729
+ if (findings.length === 0) {
47730
+ ctx.out.write("(no findings)\n");
47731
+ return 0;
47732
+ }
47733
+ for (const finding of findings) {
47734
+ ctx.out.write(
47735
+ `[${finding.observed_at}] ${finding.severity.toUpperCase()} ${finding.sentinel_id}${finding.agent_id ? ` (agent ${finding.agent_id})` : ""}: ${finding.summary}
47736
+ `
47737
+ );
47738
+ }
47739
+ return 0;
47740
+ }
47741
+ function parseFindingFilters(argv) {
47742
+ const filters = {};
47743
+ for (let i = 0; i < argv.length; i += 1) {
47744
+ const arg = argv[i];
47745
+ if (arg === "--since" && argv[i + 1]) {
47746
+ filters.since = argv[++i];
47747
+ } else if (arg === "--severity" && argv[i + 1]) {
47748
+ const next = argv[++i];
47749
+ if (next === "info" || next === "warn" || next === "alert") {
47750
+ filters.severity = next;
47751
+ }
47752
+ } else if (arg === "--sentinel-id" && argv[i + 1]) {
47753
+ filters.sentinelId = argv[++i];
47754
+ } else if (arg === "--agent-id" && argv[i + 1]) {
47755
+ filters.agentId = argv[++i];
47756
+ } else if (arg === "--limit" && argv[i + 1]) {
47757
+ const n = Number.parseInt(argv[++i], 10);
47758
+ if (!Number.isNaN(n) && n > 0) filters.limit = n;
47759
+ }
47760
+ }
47761
+ return filters;
47762
+ }
47763
+ async function resolveStoragePath2(args) {
47764
+ if (args.storagePath) return args.storagePath;
47765
+ const config = await loadConfig();
47766
+ return config.storage_path;
47767
+ }
47768
+ var init_sentinel2 = __esm({
47769
+ "src/cli/sentinel.ts"() {
47770
+ init_config();
47771
+ init_filesystem();
47772
+ init_key_derivation();
47773
+ init_encoding();
47774
+ init_passphrase();
47775
+ init_wiring();
47776
+ init_sentinel_finding_store();
47777
+ init_subscription_store();
47778
+ init_sentinels();
47779
+ }
47780
+ });
47781
+
44710
47782
  // src/mcp/broker-server.ts
44711
47783
  var broker_server_exports = {};
44712
47784
  __export(broker_server_exports, {
@@ -44802,7 +47874,7 @@ function createBrokerMcpServer(broker, opts) {
44802
47874
  case "broker/request_token": {
44803
47875
  const skill = requireString(args, "skill");
44804
47876
  const secret = requireString(args, "secret");
44805
- const scopeRaw = optionalString(args, "scope");
47877
+ const scopeRaw = optionalString2(args, "scope");
44806
47878
  const scope = scopeRaw === "rotate" ? "rotate" : scopeRaw === "read" ? "read" : void 0;
44807
47879
  const ttl = optionalNumber(args, "ttl_seconds");
44808
47880
  const binding = await broker.issueToken({
@@ -44834,7 +47906,7 @@ function createBrokerMcpServer(broker, opts) {
44834
47906
  return ok({ grants });
44835
47907
  }
44836
47908
  case "broker/audit_query": {
44837
- const since = optionalString(args, "since");
47909
+ const since = optionalString2(args, "since");
44838
47910
  const limit = optionalNumber(args, "limit");
44839
47911
  const summary = await broker.queryAudit({ since, limit });
44840
47912
  return ok(summary);
@@ -44859,7 +47931,7 @@ function requireString(args, key) {
44859
47931
  }
44860
47932
  return v;
44861
47933
  }
44862
- function optionalString(args, key) {
47934
+ function optionalString2(args, key) {
44863
47935
  const v = args[key];
44864
47936
  return typeof v === "string" && v.length > 0 ? v : void 0;
44865
47937
  }
@@ -45566,6 +48638,11 @@ async function main() {
45566
48638
  const code = await runIntelligenceCommand2({ argv: args.slice(1) });
45567
48639
  process.exit(code);
45568
48640
  }
48641
+ if (args[0] === "sentinel") {
48642
+ const { runSentinelCommand: runSentinelCommand2 } = await Promise.resolve().then(() => (init_sentinel2(), sentinel_exports));
48643
+ const code = await runSentinelCommand2({ argv: args.slice(1) });
48644
+ process.exit(code);
48645
+ }
45569
48646
  if (args[0] === "broker-server") {
45570
48647
  const { openBroker: openBroker2 } = await Promise.resolve().then(() => (init_open(), open_exports));
45571
48648
  const { createBrokerMcpServer: createBrokerMcpServer2 } = await Promise.resolve().then(() => (init_broker_server(), broker_server_exports));