@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.cjs CHANGED
@@ -5044,7 +5044,8 @@ var init_constants = __esm({
5044
5044
  RESERVED_EVENT_TYPE_PREFIXES = [
5045
5045
  "EXTENSION_",
5046
5046
  "cross_fortress_",
5047
- "multi_master_"
5047
+ "multi_master_",
5048
+ "cross_harness_approval_"
5048
5049
  ];
5049
5050
  RESERVED_EXTENSION_ENVELOPE_KEYS = [
5050
5051
  "cross_fortress_read_grant",
@@ -9236,9 +9237,9 @@ function fingerprintDID(did) {
9236
9237
  return `${raw.slice(0, 6)}\u2026${raw.slice(-6)}`;
9237
9238
  }
9238
9239
  function countInjectionsToday(audit) {
9239
- const startOfDay = /* @__PURE__ */ new Date();
9240
- startOfDay.setHours(0, 0, 0, 0);
9241
- const cutoff = startOfDay.getTime();
9240
+ const startOfDay2 = /* @__PURE__ */ new Date();
9241
+ startOfDay2.setHours(0, 0, 0, 0);
9242
+ const cutoff = startOfDay2.getTime();
9242
9243
  return audit.filter((e) => {
9243
9244
  const ts = new Date(e.timestamp).getTime();
9244
9245
  if (isNaN(ts) || ts < cutoff) return false;
@@ -9247,9 +9248,9 @@ function countInjectionsToday(audit) {
9247
9248
  }).length;
9248
9249
  }
9249
9250
  function countProofsToday(audit) {
9250
- const startOfDay = /* @__PURE__ */ new Date();
9251
- startOfDay.setHours(0, 0, 0, 0);
9252
- const cutoff = startOfDay.getTime();
9251
+ const startOfDay2 = /* @__PURE__ */ new Date();
9252
+ startOfDay2.setHours(0, 0, 0, 0);
9253
+ const cutoff = startOfDay2.getTime();
9253
9254
  return audit.filter((e) => {
9254
9255
  if (e.layer !== "l3") return false;
9255
9256
  if (!PROOF_CREATION_OPS.has(e.operation)) return false;
@@ -17218,6 +17219,24 @@ async function handleApprovalInboxRoute(deps, req, res) {
17218
17219
  await handleStream2(deps, res);
17219
17220
  return true;
17220
17221
  }
17222
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/revision`) {
17223
+ const revision = await deps.aggregator.getRevision();
17224
+ writeJSON4(res, 200, { ok: true, data: { revision } });
17225
+ return true;
17226
+ }
17227
+ if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/sync`) {
17228
+ const sinceRaw = url.searchParams.get("since_revision");
17229
+ const sinceParsed = sinceRaw === null ? 0 : Number.parseInt(sinceRaw, 10);
17230
+ const sinceRevision = Number.isFinite(sinceParsed) && sinceParsed >= 0 ? sinceParsed : 0;
17231
+ const limit = parseLimit2(
17232
+ url.searchParams.get("limit"),
17233
+ APPROVAL_INBOX_DEFAULT_LIMIT,
17234
+ APPROVAL_INBOX_MAX_LIMIT
17235
+ );
17236
+ const delta = await deps.aggregator.getSync({ sinceRevision, limit });
17237
+ writeJSON4(res, 200, { ok: true, data: delta });
17238
+ return true;
17239
+ }
17221
17240
  if (method === "GET" && path === `${APPROVAL_INBOX_API_PREFIX}/history`) {
17222
17241
  const limit = parseLimit2(
17223
17242
  url.searchParams.get("limit"),
@@ -17342,6 +17361,125 @@ var init_approval_aggregator_routes = __esm({
17342
17361
  APPROVAL_INBOX_MAX_LIMIT = 200;
17343
17362
  }
17344
17363
  });
17364
+
17365
+ // src/sentinel/sentinel-routes.ts
17366
+ function writeJSON5(res, status, payload) {
17367
+ res.writeHead(status, {
17368
+ "Content-Type": "application/json",
17369
+ "Cache-Control": "no-store"
17370
+ });
17371
+ res.end(JSON.stringify(payload));
17372
+ }
17373
+ function isSeverity(value) {
17374
+ return value === "info" || value === "warn" || value === "alert";
17375
+ }
17376
+ function parseLimit3(raw, defaultValue, max) {
17377
+ if (raw === null || raw === "") return defaultValue;
17378
+ const parsed = Number.parseInt(raw, 10);
17379
+ if (Number.isNaN(parsed) || parsed < 0) return defaultValue;
17380
+ return Math.min(parsed, max);
17381
+ }
17382
+ function matchSubscribeRoute(path) {
17383
+ const prefix = `${SENTINEL_API_PREFIX}/`;
17384
+ if (!path.startsWith(prefix)) return null;
17385
+ const rest = path.slice(prefix.length);
17386
+ if (!rest.endsWith("/subscribe")) return null;
17387
+ const sentinelId = rest.slice(0, rest.length - "/subscribe".length);
17388
+ if (sentinelId.length === 0) return null;
17389
+ return { sentinelId: decodeURIComponent(sentinelId) };
17390
+ }
17391
+ async function handleSentinelRoute(deps, req, res) {
17392
+ const host = req.headers.host || "localhost";
17393
+ const url = new URL(req.url ?? "/", `http://${host}`);
17394
+ const method = (req.method ?? "GET").toUpperCase();
17395
+ const path = url.pathname;
17396
+ if (path !== SENTINEL_API_PREFIX && !path.startsWith(`${SENTINEL_API_PREFIX}/`)) {
17397
+ return false;
17398
+ }
17399
+ const checkAuth = authMiddleware(deps.authConfig);
17400
+ if (!checkAuth(req, res, url)) return true;
17401
+ const dispatcher = deps.dispatcher;
17402
+ const registry = dispatcher.getRegistry();
17403
+ const findingStore = dispatcher.getFindingStore();
17404
+ try {
17405
+ if (method === "GET" && path === SENTINEL_API_PREFIX) {
17406
+ const catalog = registry.listCatalog();
17407
+ writeJSON5(res, 200, { ok: true, data: { catalog } });
17408
+ return true;
17409
+ }
17410
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/subscribed`) {
17411
+ const subscribed = registry.listSubscribed();
17412
+ writeJSON5(res, 200, { ok: true, data: { subscribed } });
17413
+ return true;
17414
+ }
17415
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/findings`) {
17416
+ const limit = parseLimit3(
17417
+ url.searchParams.get("limit"),
17418
+ FINDINGS_DEFAULT_LIMIT,
17419
+ FINDINGS_MAX_LIMIT
17420
+ );
17421
+ const since = url.searchParams.get("since") ?? void 0;
17422
+ const severityRaw = url.searchParams.get("severity") ?? void 0;
17423
+ const sentinelIdFilter = url.searchParams.get("sentinel_id") ?? void 0;
17424
+ const agentIdFilter = url.searchParams.get("agent_id") ?? void 0;
17425
+ const severity = severityRaw && isSeverity(severityRaw) ? severityRaw : void 0;
17426
+ const findings = await findingStore.listFindings({
17427
+ limit,
17428
+ ...since !== void 0 ? { since } : {},
17429
+ ...severity !== void 0 ? { severity } : {},
17430
+ ...sentinelIdFilter !== void 0 ? { sentinelId: sentinelIdFilter } : {},
17431
+ ...agentIdFilter !== void 0 ? { agentId: agentIdFilter } : {}
17432
+ });
17433
+ writeJSON5(res, 200, { ok: true, data: { findings } });
17434
+ return true;
17435
+ }
17436
+ const subscribeMatch = matchSubscribeRoute(path);
17437
+ if (subscribeMatch) {
17438
+ if (method === "POST") {
17439
+ try {
17440
+ await dispatcher.subscribeSentinel(subscribeMatch.sentinelId);
17441
+ writeJSON5(res, 200, {
17442
+ ok: true,
17443
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: true }
17444
+ });
17445
+ } catch (err) {
17446
+ const msg = err instanceof Error ? err.message : String(err);
17447
+ if (msg.startsWith("sentinel-registry: unknown sentinel")) {
17448
+ writeJSON5(res, 404, { ok: false, error: "not_found" });
17449
+ } else {
17450
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
17451
+ }
17452
+ }
17453
+ return true;
17454
+ }
17455
+ if (method === "DELETE") {
17456
+ const removed = await dispatcher.unsubscribeSentinel(
17457
+ subscribeMatch.sentinelId
17458
+ );
17459
+ writeJSON5(res, 200, {
17460
+ ok: true,
17461
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: false, removed }
17462
+ });
17463
+ return true;
17464
+ }
17465
+ }
17466
+ writeJSON5(res, 404, { ok: false, error: "not_found", path });
17467
+ return true;
17468
+ } catch (err) {
17469
+ const msg = err instanceof Error ? err.message : String(err);
17470
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
17471
+ return true;
17472
+ }
17473
+ }
17474
+ var SENTINEL_API_PREFIX, FINDINGS_DEFAULT_LIMIT, FINDINGS_MAX_LIMIT;
17475
+ var init_sentinel_routes = __esm({
17476
+ "src/sentinel/sentinel-routes.ts"() {
17477
+ init_auth_middleware();
17478
+ SENTINEL_API_PREFIX = "/api/sentinels";
17479
+ FINDINGS_DEFAULT_LIMIT = 100;
17480
+ FINDINGS_MAX_LIMIT = 500;
17481
+ }
17482
+ });
17345
17483
  function isDashboardViewRoute(method, path) {
17346
17484
  if (method !== "GET") return false;
17347
17485
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -17356,6 +17494,7 @@ var init_dashboard = __esm({
17356
17494
  init_system_prompt_generator();
17357
17495
  init_dispatch();
17358
17496
  init_approval_aggregator_routes();
17497
+ init_sentinel_routes();
17359
17498
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
17360
17499
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
17361
17500
  MAX_SESSIONS = 1e3;
@@ -17423,6 +17562,13 @@ var init_dashboard = __esm({
17423
17562
  * the operator-facing query / decision surface.
17424
17563
  */
17425
17564
  approvalAggregator = null;
17565
+ /**
17566
+ * v1.3 WP-V1.3-1 Phi-1 Sentinel dispatcher. Mounted additively at
17567
+ * `/api/sentinels/*` when set. Sentinel surface is read-only against
17568
+ * the audit log; subscribe/unsubscribe writes flow through the
17569
+ * dispatcher's audited paths.
17570
+ */
17571
+ sentinelDispatcher = null;
17426
17572
  constructor(config) {
17427
17573
  this.config = config;
17428
17574
  this.authToken = config.auth_token;
@@ -17482,6 +17628,14 @@ var init_dashboard = __esm({
17482
17628
  setApprovalAggregator(aggregator) {
17483
17629
  this.approvalAggregator = aggregator;
17484
17630
  }
17631
+ /**
17632
+ * v1.3 WP-V1.3-1 Phi-1: bind the Sentinel dispatcher. Once set,
17633
+ * requests to `/api/sentinels/*` route through `handleSentinelRoute`.
17634
+ * Pass `null` to detach (used by tests + during shutdown).
17635
+ */
17636
+ setSentinelDispatcher(dispatcher) {
17637
+ this.sentinelDispatcher = dispatcher;
17638
+ }
17485
17639
  /**
17486
17640
  * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17487
17641
  * before the legacy approval route table. Returns true when served.
@@ -17501,6 +17655,25 @@ var init_dashboard = __esm({
17501
17655
  res
17502
17656
  );
17503
17657
  }
17658
+ /**
17659
+ * v1.3 WP-V1.3-1 Phi-1 dispatch entry point. Routes `/api/sentinels/*`
17660
+ * requests through the sentinel router when a dispatcher has been
17661
+ * bound. Returns true when served.
17662
+ */
17663
+ async dispatchSentinel(req, res) {
17664
+ if (!this.sentinelDispatcher) return false;
17665
+ return handleSentinelRoute(
17666
+ {
17667
+ authConfig: {
17668
+ loopbackAutoAuth: this._autoAuthLocalhost,
17669
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17670
+ },
17671
+ dispatcher: this.sentinelDispatcher
17672
+ },
17673
+ req,
17674
+ res
17675
+ );
17676
+ }
17504
17677
  /**
17505
17678
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17506
17679
  * legacy route table. Returns true when the request was served by v1.1
@@ -17888,6 +18061,18 @@ var init_dashboard = __esm({
17888
18061
  });
17889
18062
  return;
17890
18063
  }
18064
+ if (this.sentinelDispatcher && url.pathname.startsWith(SENTINEL_API_PREFIX)) {
18065
+ this.dispatchSentinel(req, res).then((handled) => {
18066
+ if (handled) return;
18067
+ this.handleLegacyRequest(req, res, url, method);
18068
+ }).catch(() => {
18069
+ if (!res.headersSent) {
18070
+ res.writeHead(500, { "Content-Type": "application/json" });
18071
+ res.end(JSON.stringify({ error: "Internal server error" }));
18072
+ }
18073
+ });
18074
+ return;
18075
+ }
17891
18076
  if (this.v11Bindings) {
17892
18077
  this.dispatchV11(req, res, url, method).then((handled) => {
17893
18078
  if (handled) return;
@@ -20360,6 +20545,20 @@ var init_approval_aggregator = __esm({
20360
20545
  hydrated = false;
20361
20546
  /** Active SSE listeners. */
20362
20547
  listeners = /* @__PURE__ */ new Set();
20548
+ /**
20549
+ * Monotonic revision counter, bumped on every mutation (ingest of new
20550
+ * entry, resolve, expire, delete). Hydrated from max(last_modified_revision)
20551
+ * across persisted entries on first read; in-memory after that. v1.3
20552
+ * Upsilon-4.
20553
+ */
20554
+ currentRevision = 0;
20555
+ /**
20556
+ * Removal tombstones: aggregator_id -> revision at removal. Used by the
20557
+ * sync API to surface "removed" entries to mobile consumers between
20558
+ * polls. In-memory only; server restart clears tombstones (mobile
20559
+ * bootstraps via `list()` on reconnect). v1.3 Upsilon-4.
20560
+ */
20561
+ removedTombstones = /* @__PURE__ */ new Map();
20363
20562
  constructor(deps) {
20364
20563
  this.storage = deps.storage;
20365
20564
  this.encryptionKey = derivePurposeKey(
@@ -20394,6 +20593,113 @@ var init_approval_aggregator = __esm({
20394
20593
  this.listeners.add(listener);
20395
20594
  return () => this.listeners.delete(listener);
20396
20595
  }
20596
+ /**
20597
+ * Current aggregator revision. v1.3 Upsilon-4. Mobile companions
20598
+ * poll the lightweight `/revision` route to detect that something
20599
+ * changed before fetching a full sync delta.
20600
+ */
20601
+ async getRevision() {
20602
+ await this.hydrate();
20603
+ return this.currentRevision;
20604
+ }
20605
+ /**
20606
+ * Compute a delta since `sinceRevision`. v1.3 Upsilon-4. Mobile
20607
+ * clients poll this for cheap state-sync. Behavior:
20608
+ * - `added`: entries whose `created_at_revision > sinceRevision`.
20609
+ * - `changed`: entries that existed at `sinceRevision` but had a
20610
+ * status transition (resolve, expire) since.
20611
+ * - `removed`: aggregator_ids deleted after `sinceRevision`.
20612
+ * - `revision`: current aggregator revision; pass this back as
20613
+ * `sinceRevision` on the next call.
20614
+ *
20615
+ * `limit` caps the total count returned across all three lists,
20616
+ * prioritized as added -> changed -> removed (newer-state first).
20617
+ * When more changes exist than fit, the next call with the returned
20618
+ * revision will pick up the rest because each entry's
20619
+ * last_modified_revision is unchanged by truncation.
20620
+ */
20621
+ async getSync(opts) {
20622
+ await this.hydrate();
20623
+ await this.expireStale();
20624
+ const sinceRevision = opts?.sinceRevision ?? 0;
20625
+ const cap = Math.min(
20626
+ opts?.limit ?? DEFAULT_LIST_PAGE_SIZE,
20627
+ this.maxListLimit
20628
+ );
20629
+ const added = [];
20630
+ const changed = [];
20631
+ for (const entry of this.entries.values()) {
20632
+ const lastMod = entry.last_modified_revision ?? 0;
20633
+ if (lastMod <= sinceRevision) continue;
20634
+ const createdRev = entry.created_at_revision ?? 0;
20635
+ if (createdRev > sinceRevision) {
20636
+ added.push(entry);
20637
+ } else {
20638
+ changed.push(entry);
20639
+ }
20640
+ }
20641
+ const removed = [];
20642
+ for (const [id, rev] of this.removedTombstones) {
20643
+ if (rev > sinceRevision) removed.push(id);
20644
+ }
20645
+ added.sort(
20646
+ (a, b) => (a.last_modified_revision ?? 0) - (b.last_modified_revision ?? 0)
20647
+ );
20648
+ changed.sort(
20649
+ (a, b) => (a.last_modified_revision ?? 0) - (b.last_modified_revision ?? 0)
20650
+ );
20651
+ let remaining = cap;
20652
+ const addedOut = added.slice(0, Math.max(0, remaining));
20653
+ remaining -= addedOut.length;
20654
+ const changedOut = changed.slice(0, Math.max(0, remaining));
20655
+ remaining -= changedOut.length;
20656
+ const removedOut = removed.slice(0, Math.max(0, remaining));
20657
+ return {
20658
+ revision: this.currentRevision,
20659
+ added: addedOut,
20660
+ changed: changedOut,
20661
+ removed: removedOut
20662
+ };
20663
+ }
20664
+ /**
20665
+ * Delete an entry. Drops the in-memory record, the persisted bundle,
20666
+ * and the at-rest payload (if a payload store is wired). Records a
20667
+ * tombstone with the new revision so sync-API consumers see a
20668
+ * `removed` delta. Returns true when an entry was deleted, false on
20669
+ * unknown id. v1.3 Upsilon-4. Reserved for v1.4+ retention housekeeping;
20670
+ * Upsilon-4 ships the surface so mobile sync-API tests can exercise the
20671
+ * removal path.
20672
+ */
20673
+ async deleteEntry(aggregatorId) {
20674
+ await this.hydrate();
20675
+ const entry = this.entries.get(aggregatorId);
20676
+ if (!entry) return false;
20677
+ const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20678
+ this.entries.delete(aggregatorId);
20679
+ this.dedupIndex.delete(dedupKey);
20680
+ this.fullPayloads.delete(aggregatorId);
20681
+ for (const [corr, id] of this.correlationIndex) {
20682
+ if (id === aggregatorId) this.correlationIndex.delete(corr);
20683
+ }
20684
+ try {
20685
+ await this.storage.delete(APPROVAL_AGGREGATOR_NAMESPACE, aggregatorId);
20686
+ } catch {
20687
+ }
20688
+ if (this.payloadStore) {
20689
+ try {
20690
+ await this.payloadStore.deletePayload(aggregatorId);
20691
+ } catch {
20692
+ }
20693
+ }
20694
+ const revision = this.nextRevision();
20695
+ this.removedTombstones.set(aggregatorId, revision);
20696
+ this.emit({ type: "removed", entry: { ...entry } });
20697
+ return true;
20698
+ }
20699
+ nextRevision() {
20700
+ this.currentRevision += 1;
20701
+ return this.currentRevision;
20702
+ }
20397
20703
  /**
20398
20704
  * Ingest a gate event. Returns the aggregator entry on first sight,
20399
20705
  * `null` when deduped. Resolution events update the existing record;
@@ -20604,6 +20910,7 @@ var init_approval_aggregator = __esm({
20604
20910
  entry.status = decision;
20605
20911
  entry.resolved_at = this.now().toISOString();
20606
20912
  entry.resolved_by = operatorId;
20913
+ entry.last_modified_revision = this.nextRevision();
20607
20914
  await this.persist(entry);
20608
20915
  this.auditLog.append(
20609
20916
  "l2",
@@ -20655,6 +20962,7 @@ var init_approval_aggregator = __esm({
20655
20962
  const expires = new Date(now.getTime() + this.pendingTtlMs);
20656
20963
  const hubInboxId = this.resolveHubInboxItemId(event);
20657
20964
  const enforcementChain = this.resolveEnforcementChain(event);
20965
+ const revision = this.nextRevision();
20658
20966
  const entry = {
20659
20967
  aggregator_id: id,
20660
20968
  source_harness: ctx.source_harness,
@@ -20666,6 +20974,8 @@ var init_approval_aggregator = __esm({
20666
20974
  status: "pending",
20667
20975
  created_at: now.toISOString(),
20668
20976
  expires_at: expires.toISOString(),
20977
+ created_at_revision: revision,
20978
+ last_modified_revision: revision,
20669
20979
  ...hubInboxId !== void 0 ? { hub_inbox_item_id: hubInboxId } : {},
20670
20980
  ...enforcementChain.length > 0 ? { enforcement_chain: enforcementChain } : {}
20671
20981
  };
@@ -20708,6 +21018,7 @@ var init_approval_aggregator = __esm({
20708
21018
  entry.status = status;
20709
21019
  entry.resolved_at = event.resolution.decided_at;
20710
21020
  entry.resolved_by = event.resolution.decided_by;
21021
+ entry.last_modified_revision = this.nextRevision();
20711
21022
  await this.persist(entry);
20712
21023
  this.auditLog.append(
20713
21024
  "l2",
@@ -20770,6 +21081,7 @@ var init_approval_aggregator = __esm({
20770
21081
  entry.status = "expired";
20771
21082
  entry.resolved_at = this.now().toISOString();
20772
21083
  entry.resolved_by = "system_ttl";
21084
+ entry.last_modified_revision = this.nextRevision();
20773
21085
  await this.persist(entry);
20774
21086
  this.auditLog.append(
20775
21087
  "l2",
@@ -20816,6 +21128,10 @@ var init_approval_aggregator = __esm({
20816
21128
  this.entries.set(entry.aggregator_id, entry);
20817
21129
  const dedupKey = `${entry.source_harness}|${entry.source_agent_id}|${entry.audit_log_entry_id}`;
20818
21130
  this.dedupIndex.set(dedupKey, entry.aggregator_id);
21131
+ const lastMod = entry.last_modified_revision ?? 0;
21132
+ if (lastMod > this.currentRevision) {
21133
+ this.currentRevision = lastMod;
21134
+ }
20819
21135
  } catch {
20820
21136
  }
20821
21137
  }
@@ -21138,6 +21454,1725 @@ var init_aggregator_store = __esm({
21138
21454
  }
21139
21455
  });
21140
21456
 
21457
+ // src/sentinel/types.ts
21458
+ function isProxyCallAuditEntry(entry) {
21459
+ return entry.operation.startsWith(
21460
+ SENTINEL_OBSERVED_AUDIT_OPS.PROXY_CALL_PREFIX
21461
+ );
21462
+ }
21463
+ function proxyServerFromAuditEntry(entry) {
21464
+ if (!isProxyCallAuditEntry(entry)) return null;
21465
+ const details = entry.details;
21466
+ if (!details) return null;
21467
+ const server = details["server"];
21468
+ if (typeof server !== "string" || server.length === 0) return null;
21469
+ return server;
21470
+ }
21471
+ var SENTINEL_SUMMARY_MAX_CHARS, SENTINEL_AUDIT_OPS, SENTINEL_OBSERVED_AUDIT_OPS;
21472
+ var init_types3 = __esm({
21473
+ "src/sentinel/types.ts"() {
21474
+ SENTINEL_SUMMARY_MAX_CHARS = 240;
21475
+ SENTINEL_AUDIT_OPS = {
21476
+ SUBSCRIBED: "sentinel_subscribed",
21477
+ UNSUBSCRIBED: "sentinel_unsubscribed",
21478
+ FINDING_EMITTED: "sentinel_finding_emitted",
21479
+ EVALUATION_FAILED: "sentinel_evaluation_failed"
21480
+ };
21481
+ SENTINEL_OBSERVED_AUDIT_OPS = {
21482
+ /** Proxy router emits this on every outbound call (success or failure). */
21483
+ PROXY_CALL_PREFIX: "proxy_call:"
21484
+ };
21485
+ }
21486
+ });
21487
+
21488
+ // src/sentinel/sentinel-finding-store.ts
21489
+ function findingKey(findingId) {
21490
+ return `${SENTINEL_FINDING_KEY_PREFIX}${findingId}`;
21491
+ }
21492
+ function stripKeyPrefix2(key) {
21493
+ if (!key.startsWith(SENTINEL_FINDING_KEY_PREFIX)) return null;
21494
+ return key.slice(SENTINEL_FINDING_KEY_PREFIX.length);
21495
+ }
21496
+ function truncateSummary(summary) {
21497
+ if (summary.length <= SENTINEL_SUMMARY_MAX_CHARS) return summary;
21498
+ return `${summary.slice(0, SENTINEL_SUMMARY_MAX_CHARS - 3)}...`;
21499
+ }
21500
+ var SENTINEL_FINDING_NAMESPACE, SENTINEL_FINDING_KEY_PREFIX, HKDF_INFO2, DEFAULT_SENTINEL_FINDING_RETENTION_DAYS, MAX_FINDING_BYTES, SentinelFindingStore;
21501
+ var init_sentinel_finding_store = __esm({
21502
+ "src/sentinel/sentinel-finding-store.ts"() {
21503
+ init_encryption();
21504
+ init_key_derivation();
21505
+ init_encoding();
21506
+ init_types3();
21507
+ SENTINEL_FINDING_NAMESPACE = "_sentinel_findings";
21508
+ SENTINEL_FINDING_KEY_PREFIX = "finding.";
21509
+ HKDF_INFO2 = "l2-sentinel-finding-v1";
21510
+ DEFAULT_SENTINEL_FINDING_RETENTION_DAYS = 30;
21511
+ MAX_FINDING_BYTES = 256 * 1024;
21512
+ SentinelFindingStore = class {
21513
+ storage;
21514
+ encryptionKey;
21515
+ fortressId;
21516
+ retentionDays;
21517
+ now;
21518
+ constructor(opts) {
21519
+ this.storage = opts.storage;
21520
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
21521
+ this.fortressId = opts.fortressId;
21522
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_SENTINEL_FINDING_RETENTION_DAYS;
21523
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
21524
+ }
21525
+ /**
21526
+ * Persist a finding. Truncates the operator-visible summary to
21527
+ * SENTINEL_SUMMARY_MAX_CHARS so the dashboard render stays bounded.
21528
+ * Returns the retention deadline so callers can audit it.
21529
+ */
21530
+ async saveFinding(finding) {
21531
+ const truncated = {
21532
+ ...finding,
21533
+ fortress_id: this.fortressId,
21534
+ summary: truncateSummary(finding.summary)
21535
+ };
21536
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
21537
+ const retentionUntil = new Date(this.now().getTime() + retentionMs);
21538
+ const persisted = {
21539
+ version: 1,
21540
+ finding: truncated,
21541
+ retention_until: retentionUntil.toISOString()
21542
+ };
21543
+ const aad = stringToBytes(finding.finding_id);
21544
+ const plaintext = stringToBytes(JSON.stringify(persisted));
21545
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
21546
+ await this.storage.write(
21547
+ SENTINEL_FINDING_NAMESPACE,
21548
+ findingKey(finding.finding_id),
21549
+ stringToBytes(JSON.stringify(envelope))
21550
+ );
21551
+ return persisted.retention_until;
21552
+ }
21553
+ /** Load a single finding by id, or null when absent / corrupted. */
21554
+ async loadFinding(findingId) {
21555
+ let raw;
21556
+ try {
21557
+ raw = await this.storage.read(
21558
+ SENTINEL_FINDING_NAMESPACE,
21559
+ findingKey(findingId)
21560
+ );
21561
+ } catch {
21562
+ return null;
21563
+ }
21564
+ if (!raw) return null;
21565
+ if (raw.length > MAX_FINDING_BYTES) return null;
21566
+ return this.decode(findingId, raw);
21567
+ }
21568
+ /**
21569
+ * List findings, newest first. Optional filters: since (ISO 8601),
21570
+ * severity, sentinel_id, agent_id, limit. Default limit 100.
21571
+ */
21572
+ async listFindings(opts) {
21573
+ const metas = await this.storage.list(
21574
+ SENTINEL_FINDING_NAMESPACE,
21575
+ SENTINEL_FINDING_KEY_PREFIX
21576
+ );
21577
+ const findings = [];
21578
+ for (const meta of metas) {
21579
+ const id = stripKeyPrefix2(meta.key);
21580
+ if (id === null) continue;
21581
+ const raw = await this.storage.read(
21582
+ SENTINEL_FINDING_NAMESPACE,
21583
+ meta.key
21584
+ );
21585
+ if (!raw) continue;
21586
+ if (raw.length > MAX_FINDING_BYTES) continue;
21587
+ const finding = await this.decode(id, raw);
21588
+ if (!finding) continue;
21589
+ if (opts?.since && finding.observed_at < opts.since) continue;
21590
+ if (opts?.severity && finding.severity !== opts.severity) continue;
21591
+ if (opts?.sentinelId && finding.sentinel_id !== opts.sentinelId) continue;
21592
+ if (opts?.agentId && finding.agent_id !== opts.agentId) continue;
21593
+ findings.push(finding);
21594
+ }
21595
+ findings.sort((a, b) => a.observed_at < b.observed_at ? 1 : -1);
21596
+ const limit = opts?.limit ?? 100;
21597
+ return findings.slice(0, limit);
21598
+ }
21599
+ /**
21600
+ * Drop expired findings. Returns the count removed.
21601
+ */
21602
+ async pruneExpired(now) {
21603
+ const cutoff = (now ?? this.now()).toISOString();
21604
+ const metas = await this.storage.list(
21605
+ SENTINEL_FINDING_NAMESPACE,
21606
+ SENTINEL_FINDING_KEY_PREFIX
21607
+ );
21608
+ let pruned = 0;
21609
+ for (const meta of metas) {
21610
+ const id = stripKeyPrefix2(meta.key);
21611
+ if (id === null) continue;
21612
+ const raw = await this.storage.read(
21613
+ SENTINEL_FINDING_NAMESPACE,
21614
+ meta.key
21615
+ );
21616
+ if (!raw) continue;
21617
+ try {
21618
+ const aad = stringToBytes(id);
21619
+ const envelope = JSON.parse(bytesToString(raw));
21620
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21621
+ const persisted = JSON.parse(
21622
+ bytesToString(plaintext)
21623
+ );
21624
+ if (persisted.retention_until <= cutoff) {
21625
+ await this.storage.delete(SENTINEL_FINDING_NAMESPACE, meta.key);
21626
+ pruned += 1;
21627
+ }
21628
+ } catch {
21629
+ }
21630
+ }
21631
+ return { pruned };
21632
+ }
21633
+ async decode(findingId, raw) {
21634
+ try {
21635
+ const aad = stringToBytes(findingId);
21636
+ const envelope = JSON.parse(bytesToString(raw));
21637
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21638
+ const persisted = JSON.parse(
21639
+ bytesToString(plaintext)
21640
+ );
21641
+ if (persisted.version !== 1) return null;
21642
+ if (persisted.finding.finding_id !== findingId) return null;
21643
+ if (persisted.finding.fortress_id !== this.fortressId) return null;
21644
+ return persisted.finding;
21645
+ } catch {
21646
+ return null;
21647
+ }
21648
+ }
21649
+ };
21650
+ }
21651
+ });
21652
+
21653
+ // src/sentinel/sentinel-registry.ts
21654
+ var SentinelRegistry;
21655
+ var init_sentinel_registry = __esm({
21656
+ "src/sentinel/sentinel-registry.ts"() {
21657
+ SentinelRegistry = class {
21658
+ catalog = /* @__PURE__ */ new Map();
21659
+ subscribed = /* @__PURE__ */ new Map();
21660
+ register(entry) {
21661
+ if (this.catalog.has(entry.sentinelId)) {
21662
+ throw new Error(
21663
+ `sentinel-registry: ${entry.sentinelId} already registered`
21664
+ );
21665
+ }
21666
+ this.catalog.set(entry.sentinelId, entry);
21667
+ }
21668
+ /**
21669
+ * Available sentinels (catalog view). Operator UI lists this so the
21670
+ * operator can pick what to subscribe to.
21671
+ */
21672
+ listCatalog() {
21673
+ return [...this.catalog.values()].map((entry) => ({
21674
+ sentinelId: entry.sentinelId,
21675
+ description: entry.description
21676
+ }));
21677
+ }
21678
+ /** Currently subscribed sentinel ids. */
21679
+ listSubscribed() {
21680
+ return [...this.subscribed.keys()];
21681
+ }
21682
+ /** Has the fortress opted into this sentinel? */
21683
+ isSubscribed(sentinelId) {
21684
+ return this.subscribed.has(sentinelId);
21685
+ }
21686
+ /**
21687
+ * Subscribe a sentinel to a fortress context. Idempotent: a second
21688
+ * subscribe call on an already-subscribed sentinel returns the
21689
+ * existing instance without re-running `subscribe()`.
21690
+ */
21691
+ async subscribe(sentinelId, context) {
21692
+ const existing = this.subscribed.get(sentinelId);
21693
+ if (existing) return existing;
21694
+ const entry = this.catalog.get(sentinelId);
21695
+ if (!entry) {
21696
+ throw new Error(`sentinel-registry: unknown sentinel ${sentinelId}`);
21697
+ }
21698
+ const instance = entry.factory();
21699
+ await instance.subscribe(context);
21700
+ this.subscribed.set(sentinelId, instance);
21701
+ return instance;
21702
+ }
21703
+ /**
21704
+ * Unsubscribe. Idempotent: unsubscribing an unsubscribed sentinel
21705
+ * returns false without throwing. Returns true when an active
21706
+ * subscription was torn down.
21707
+ */
21708
+ async unsubscribe(sentinelId) {
21709
+ const instance = this.subscribed.get(sentinelId);
21710
+ if (!instance) return false;
21711
+ try {
21712
+ await instance.unsubscribe();
21713
+ } finally {
21714
+ this.subscribed.delete(sentinelId);
21715
+ }
21716
+ return true;
21717
+ }
21718
+ /**
21719
+ * Snapshot of subscribed sentinels for the dispatcher's tick path.
21720
+ * Returned as an array so the dispatcher can iterate without holding
21721
+ * the map under modification.
21722
+ */
21723
+ snapshotSubscribed() {
21724
+ return [...this.subscribed.entries()].map(([sentinelId, sentinel]) => ({
21725
+ sentinelId,
21726
+ sentinel
21727
+ }));
21728
+ }
21729
+ /**
21730
+ * Tear down every subscription. Called by the dispatcher on
21731
+ * fortress-shutdown. Best-effort: a failing unsubscribe does not
21732
+ * abort the rest.
21733
+ */
21734
+ async unsubscribeAll() {
21735
+ const ids = [...this.subscribed.keys()];
21736
+ for (const id of ids) {
21737
+ try {
21738
+ await this.unsubscribe(id);
21739
+ } catch {
21740
+ }
21741
+ }
21742
+ }
21743
+ };
21744
+ }
21745
+ });
21746
+ var DEFAULT_TICK_INTERVAL_MS, SentinelDispatcher;
21747
+ var init_sentinel_dispatcher = __esm({
21748
+ "src/sentinel/sentinel-dispatcher.ts"() {
21749
+ init_types3();
21750
+ DEFAULT_TICK_INTERVAL_MS = 6e4;
21751
+ SentinelDispatcher = class {
21752
+ registry;
21753
+ findingStore;
21754
+ auditLog;
21755
+ fortressId;
21756
+ identityId;
21757
+ now;
21758
+ tickIntervalMs;
21759
+ listeners = /* @__PURE__ */ new Set();
21760
+ tickTimer = null;
21761
+ tickInFlight = false;
21762
+ constructor(deps) {
21763
+ this.registry = deps.registry;
21764
+ this.findingStore = deps.findingStore;
21765
+ this.auditLog = deps.auditLog;
21766
+ this.fortressId = deps.fortressId;
21767
+ this.identityId = deps.identityId;
21768
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
21769
+ this.tickIntervalMs = deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
21770
+ }
21771
+ /** Read-only view of the registry. Convenience for route handlers. */
21772
+ getRegistry() {
21773
+ return this.registry;
21774
+ }
21775
+ /** Read-only view of the finding store. Convenience for route handlers. */
21776
+ getFindingStore() {
21777
+ return this.findingStore;
21778
+ }
21779
+ /**
21780
+ * Subscribe an in-process listener. Returns an unsubscribe fn.
21781
+ */
21782
+ onEvent(listener) {
21783
+ this.listeners.add(listener);
21784
+ return () => this.listeners.delete(listener);
21785
+ }
21786
+ /**
21787
+ * Subscribe a sentinel to this fortress + emit the
21788
+ * `sentinel_subscribed` audit event. Wraps `registry.subscribe()` so
21789
+ * the audit emission lives at the dispatcher boundary (the
21790
+ * fortress-aware site).
21791
+ */
21792
+ async subscribeSentinel(sentinelId, contextOverrides) {
21793
+ const context = {
21794
+ fortressId: this.fortressId,
21795
+ auditLog: this.auditLog,
21796
+ now: this.now,
21797
+ ...contextOverrides ?? {}
21798
+ };
21799
+ const sentinel = await this.registry.subscribe(sentinelId, context);
21800
+ this.auditLog.append(
21801
+ "l2",
21802
+ SENTINEL_AUDIT_OPS.SUBSCRIBED,
21803
+ this.identityId,
21804
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
21805
+ );
21806
+ return sentinel;
21807
+ }
21808
+ /**
21809
+ * Unsubscribe + emit `sentinel_unsubscribed`. Returns true when an
21810
+ * active subscription was torn down. Audit fires only on successful
21811
+ * removal.
21812
+ */
21813
+ async unsubscribeSentinel(sentinelId) {
21814
+ const removed = await this.registry.unsubscribe(sentinelId);
21815
+ if (removed) {
21816
+ this.auditLog.append(
21817
+ "l2",
21818
+ SENTINEL_AUDIT_OPS.UNSUBSCRIBED,
21819
+ this.identityId,
21820
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
21821
+ );
21822
+ }
21823
+ return removed;
21824
+ }
21825
+ /**
21826
+ * Run one evaluation pass over every subscribed sentinel. Used by
21827
+ * the auto-tick AND by tests that want a synchronous evaluation
21828
+ * gate. Returns the findings produced this tick (already persisted
21829
+ * + audit-logged + emitted).
21830
+ */
21831
+ async tick() {
21832
+ if (this.tickInFlight) return [];
21833
+ this.tickInFlight = true;
21834
+ try {
21835
+ const subscribed = this.registry.snapshotSubscribed();
21836
+ const findings = [];
21837
+ for (const { sentinelId, sentinel } of subscribed) {
21838
+ try {
21839
+ const tickFindings = await sentinel.evaluate();
21840
+ for (const finding of tickFindings) {
21841
+ const stamped = await this.routeFinding(sentinelId, finding);
21842
+ findings.push(stamped);
21843
+ }
21844
+ } catch (err) {
21845
+ const errorMessage = err instanceof Error ? err.message : String(err);
21846
+ const observedAt = this.now().toISOString();
21847
+ this.auditLog.append(
21848
+ "l2",
21849
+ SENTINEL_AUDIT_OPS.EVALUATION_FAILED,
21850
+ this.identityId,
21851
+ {
21852
+ sentinel_id: sentinelId,
21853
+ fortress_id: this.fortressId,
21854
+ error_message: errorMessage
21855
+ },
21856
+ "failure"
21857
+ );
21858
+ this.emit({
21859
+ type: "evaluation_failed",
21860
+ sentinel_id: sentinelId,
21861
+ error_message: errorMessage,
21862
+ observed_at: observedAt
21863
+ });
21864
+ }
21865
+ }
21866
+ return findings;
21867
+ } finally {
21868
+ this.tickInFlight = false;
21869
+ }
21870
+ }
21871
+ /**
21872
+ * Start the auto-tick loop. No-op when tickIntervalMs is 0 or when
21873
+ * already started. Tests typically leave auto-tick off and call
21874
+ * `tick()` directly.
21875
+ */
21876
+ start() {
21877
+ if (this.tickTimer !== null) return;
21878
+ if (this.tickIntervalMs <= 0) return;
21879
+ this.tickTimer = setInterval(() => {
21880
+ void this.tick();
21881
+ }, this.tickIntervalMs);
21882
+ if (typeof this.tickTimer.unref === "function") {
21883
+ this.tickTimer.unref();
21884
+ }
21885
+ }
21886
+ /** Stop the auto-tick loop. Idempotent. */
21887
+ stop() {
21888
+ if (this.tickTimer === null) return;
21889
+ clearInterval(this.tickTimer);
21890
+ this.tickTimer = null;
21891
+ }
21892
+ /**
21893
+ * Tear down every subscription + stop the tick loop. Called on
21894
+ * fortress shutdown.
21895
+ */
21896
+ async dispose() {
21897
+ this.stop();
21898
+ await this.registry.unsubscribeAll();
21899
+ this.listeners.clear();
21900
+ }
21901
+ async routeFinding(sentinelId, raw) {
21902
+ const stamped = {
21903
+ ...raw,
21904
+ finding_id: raw.finding_id || crypto.randomUUID(),
21905
+ sentinel_id: sentinelId,
21906
+ fortress_id: this.fortressId,
21907
+ observed_at: raw.observed_at || this.now().toISOString()
21908
+ };
21909
+ await this.findingStore.saveFinding(stamped);
21910
+ this.auditLog.append(
21911
+ "l2",
21912
+ SENTINEL_AUDIT_OPS.FINDING_EMITTED,
21913
+ this.identityId,
21914
+ {
21915
+ sentinel_id: sentinelId,
21916
+ finding_id: stamped.finding_id,
21917
+ severity: stamped.severity,
21918
+ ...stamped.agent_id !== void 0 ? { agent_id: stamped.agent_id } : {},
21919
+ evidence_audit_ids: stamped.evidence_audit_ids,
21920
+ fortress_id: this.fortressId
21921
+ }
21922
+ );
21923
+ this.emit({ type: "finding", finding: stamped });
21924
+ return stamped;
21925
+ }
21926
+ emit(event) {
21927
+ for (const listener of this.listeners) {
21928
+ try {
21929
+ listener(event);
21930
+ } catch {
21931
+ }
21932
+ }
21933
+ }
21934
+ };
21935
+ }
21936
+ });
21937
+
21938
+ // src/sentinel/sentinel.ts
21939
+ var Sentinel;
21940
+ var init_sentinel = __esm({
21941
+ "src/sentinel/sentinel.ts"() {
21942
+ Sentinel = class {
21943
+ /**
21944
+ * Bind the sentinel to a fortress context. Called once on
21945
+ * subscribe. Default implementation stores the context on `this`;
21946
+ * sentinels that need additional setup (e.g. priming a baseline
21947
+ * cache) override.
21948
+ */
21949
+ async subscribe(context) {
21950
+ this.context = context;
21951
+ }
21952
+ /**
21953
+ * Tear down. Default implementation clears the context; subclasses
21954
+ * that hold timers or external handles override.
21955
+ */
21956
+ async unsubscribe() {
21957
+ this.context = void 0;
21958
+ }
21959
+ context;
21960
+ /** Internal helper: assert subscribed before evaluation. */
21961
+ requireContext() {
21962
+ if (!this.context) {
21963
+ throw new Error(
21964
+ `sentinel ${this.sentinelId}: evaluate() called before subscribe()`
21965
+ );
21966
+ }
21967
+ return this.context;
21968
+ }
21969
+ };
21970
+ }
21971
+ });
21972
+
21973
+ // src/sentinel/sentinels/egress-volume-watcher.ts
21974
+ var EGRESS_VOLUME_SENTINEL_ID, WARN_SIGMA, ALERT_SIGMA, BASELINE_WINDOWS, QUERY_LIMIT, EgressVolumeWatcher;
21975
+ var init_egress_volume_watcher = __esm({
21976
+ "src/sentinel/sentinels/egress-volume-watcher.ts"() {
21977
+ init_sentinel();
21978
+ init_types3();
21979
+ EGRESS_VOLUME_SENTINEL_ID = "egress-volume";
21980
+ WARN_SIGMA = 3;
21981
+ ALERT_SIGMA = 6;
21982
+ BASELINE_WINDOWS = 7;
21983
+ QUERY_LIMIT = 1e4;
21984
+ EgressVolumeWatcher = class extends Sentinel {
21985
+ sentinelId = EGRESS_VOLUME_SENTINEL_ID;
21986
+ 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.";
21987
+ /** Servers we have already produced an `info` baseline-established finding for. */
21988
+ baselineEstablished = /* @__PURE__ */ new Set();
21989
+ async evaluate() {
21990
+ const ctx = this.requireContext();
21991
+ const now = ctx.now();
21992
+ const windowMs = 24 * 60 * 60 * 1e3;
21993
+ const windowSpanMs = (BASELINE_WINDOWS + 1) * windowMs;
21994
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21995
+ const queryResult = await ctx.auditLog.query({
21996
+ since: sinceIso,
21997
+ layer: "l2",
21998
+ limit: QUERY_LIMIT
21999
+ });
22000
+ const entries = queryResult.entries.filter(isProxyCallAuditEntry);
22001
+ const byServer = /* @__PURE__ */ new Map();
22002
+ for (const entry of entries) {
22003
+ const server = proxyServerFromAuditEntry(entry);
22004
+ if (server === null) continue;
22005
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
22006
+ if (auditAge < 0) continue;
22007
+ const windowIdx = Math.floor(auditAge / windowMs);
22008
+ if (windowIdx > BASELINE_WINDOWS) continue;
22009
+ let snapshot = byServer.get(server);
22010
+ if (!snapshot) {
22011
+ snapshot = { windows: [] };
22012
+ for (let i = 0; i <= BASELINE_WINDOWS; i += 1) {
22013
+ snapshot.windows.push({ count: 0, evidence_audit_ids: [] });
22014
+ }
22015
+ byServer.set(server, snapshot);
22016
+ }
22017
+ const bucket = snapshot.windows[windowIdx];
22018
+ bucket.count += 1;
22019
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22020
+ bucket.evidence_audit_ids.push(`${entry.timestamp}:${entry.operation}`);
22021
+ }
22022
+ }
22023
+ const findings = [];
22024
+ for (const [server, snapshot] of byServer.entries()) {
22025
+ const finding = this.evaluateServer(server, snapshot, now);
22026
+ if (finding) findings.push(finding);
22027
+ }
22028
+ return findings;
22029
+ }
22030
+ /** Reset baseline-established memoization. Tests use this between runs. */
22031
+ resetBaselineMemo() {
22032
+ this.baselineEstablished.clear();
22033
+ }
22034
+ evaluateServer(server, snapshot, now) {
22035
+ const currentWindow = snapshot.windows[0];
22036
+ const baselineWindows = snapshot.windows.slice(1);
22037
+ const populatedBaselineWindows = baselineWindows.filter((w) => w.count > 0).length;
22038
+ if (populatedBaselineWindows < BASELINE_WINDOWS) {
22039
+ if (this.baselineEstablished.has(server)) return null;
22040
+ if (populatedBaselineWindows === 0 && currentWindow.count === 0) {
22041
+ return null;
22042
+ }
22043
+ return null;
22044
+ }
22045
+ const baselineCounts = baselineWindows.map((w) => w.count);
22046
+ const mean = baselineCounts.reduce((sum, c) => sum + c, 0) / baselineCounts.length;
22047
+ const variance = baselineCounts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / baselineCounts.length;
22048
+ const stddev = Math.sqrt(variance);
22049
+ const wasEstablished = this.baselineEstablished.has(server);
22050
+ this.baselineEstablished.add(server);
22051
+ if (!wasEstablished) {
22052
+ return {
22053
+ finding_id: "",
22054
+ sentinel_id: this.sentinelId,
22055
+ severity: "info",
22056
+ summary: `egress-volume baseline established for ${server}: mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS} prior days).`,
22057
+ details: {
22058
+ server,
22059
+ baseline_mean: mean,
22060
+ baseline_stddev: stddev,
22061
+ baseline_windows: baselineCounts,
22062
+ current_count: currentWindow.count
22063
+ },
22064
+ observed_at: now.toISOString(),
22065
+ evidence_audit_ids: [],
22066
+ fortress_id: ""
22067
+ };
22068
+ }
22069
+ const warnThreshold = mean + WARN_SIGMA * stddev;
22070
+ const alertThreshold = mean + ALERT_SIGMA * stddev;
22071
+ if (currentWindow.count > alertThreshold) {
22072
+ return this.buildAnomalyFinding(
22073
+ server,
22074
+ snapshot,
22075
+ mean,
22076
+ stddev,
22077
+ now,
22078
+ "alert",
22079
+ ALERT_SIGMA
22080
+ );
22081
+ }
22082
+ if (currentWindow.count > warnThreshold) {
22083
+ return this.buildAnomalyFinding(
22084
+ server,
22085
+ snapshot,
22086
+ mean,
22087
+ stddev,
22088
+ now,
22089
+ "warn",
22090
+ WARN_SIGMA
22091
+ );
22092
+ }
22093
+ return null;
22094
+ }
22095
+ buildAnomalyFinding(server, snapshot, mean, stddev, now, severity, sigma) {
22096
+ const currentWindow = snapshot.windows[0];
22097
+ const ratio = mean === 0 ? Infinity : currentWindow.count / mean;
22098
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22099
+ const summary = `${server} egress is ${ratioStr}: ${currentWindow.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
22100
+ return {
22101
+ finding_id: "",
22102
+ sentinel_id: this.sentinelId,
22103
+ severity,
22104
+ summary,
22105
+ details: {
22106
+ server,
22107
+ current_count: currentWindow.count,
22108
+ baseline_mean: mean,
22109
+ baseline_stddev: stddev,
22110
+ sigma_threshold: sigma,
22111
+ ratio
22112
+ },
22113
+ observed_at: now.toISOString(),
22114
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
22115
+ fortress_id: ""
22116
+ };
22117
+ }
22118
+ };
22119
+ }
22120
+ });
22121
+
22122
+ // src/sentinel/sentinels/cross-agent-chatter-watcher.ts
22123
+ function pairKey(sender, recipient) {
22124
+ return `${sender}|${recipient}`;
22125
+ }
22126
+ function pairFromKey(key) {
22127
+ const idx = key.indexOf("|");
22128
+ return { sender: key.slice(0, idx), recipient: key.slice(idx + 1) };
22129
+ }
22130
+ function computeNewPartners(byPair) {
22131
+ const currentBySource = /* @__PURE__ */ new Map();
22132
+ const priorBySource = /* @__PURE__ */ new Map();
22133
+ for (const [key, snap] of byPair.entries()) {
22134
+ const { sender, recipient } = pairFromKey(key);
22135
+ if (snap.windows[0] && snap.windows[0].count > 0) {
22136
+ let recipMap = currentBySource.get(sender);
22137
+ if (!recipMap) {
22138
+ recipMap = /* @__PURE__ */ new Map();
22139
+ currentBySource.set(sender, recipMap);
22140
+ }
22141
+ recipMap.set(recipient, snap.windows[0].evidence_audit_ids);
22142
+ }
22143
+ const priorTouched = snap.windows.slice(1).some((w) => w.count > 0);
22144
+ if (priorTouched) {
22145
+ let set = priorBySource.get(sender);
22146
+ if (!set) {
22147
+ set = /* @__PURE__ */ new Set();
22148
+ priorBySource.set(sender, set);
22149
+ }
22150
+ set.add(recipient);
22151
+ }
22152
+ }
22153
+ const out = /* @__PURE__ */ new Map();
22154
+ for (const [sender, recipMap] of currentBySource.entries()) {
22155
+ const prior = priorBySource.get(sender) ?? /* @__PURE__ */ new Set();
22156
+ if (prior.size === 0) {
22157
+ continue;
22158
+ }
22159
+ const newPartners = [];
22160
+ const evidence = [];
22161
+ for (const [recipient, recipEvidence] of recipMap.entries()) {
22162
+ if (!prior.has(recipient)) {
22163
+ newPartners.push(recipient);
22164
+ for (const id of recipEvidence) {
22165
+ if (evidence.length < 50) evidence.push(id);
22166
+ }
22167
+ }
22168
+ }
22169
+ if (newPartners.length === 0) continue;
22170
+ newPartners.sort();
22171
+ out.set(sender, {
22172
+ partners: newPartners,
22173
+ priorPartners: [...prior].sort(),
22174
+ evidenceAuditIds: evidence
22175
+ });
22176
+ }
22177
+ return out;
22178
+ }
22179
+ function extractInterAgentEvents(entries) {
22180
+ const out = [];
22181
+ for (const entry of entries) {
22182
+ const op = entry.operation;
22183
+ if (op === HANDOFF_OP) {
22184
+ const details = entry.details;
22185
+ const sender = optionalString(details, "sender_agent_id");
22186
+ const recipient = optionalString(details, "recipient_agent_id");
22187
+ if (!sender || !recipient || sender === recipient) continue;
22188
+ out.push({
22189
+ sender,
22190
+ recipient,
22191
+ timestampMs: Date.parse(entry.timestamp),
22192
+ auditId: `${entry.timestamp}:${entry.operation}`
22193
+ });
22194
+ continue;
22195
+ }
22196
+ if (CROSS_HARNESS_OPS.has(op)) {
22197
+ const details = entry.details;
22198
+ const sender = optionalString(details, "source_harness") ?? optionalString(details, "source_agent_id");
22199
+ if (!sender) continue;
22200
+ out.push({
22201
+ sender,
22202
+ recipient: OPERATOR_PSEUDO_AGENT,
22203
+ timestampMs: Date.parse(entry.timestamp),
22204
+ auditId: `${entry.timestamp}:${entry.operation}`
22205
+ });
22206
+ }
22207
+ }
22208
+ return out;
22209
+ }
22210
+ function optionalString(details, key) {
22211
+ if (!details) return null;
22212
+ const value = details[key];
22213
+ if (typeof value !== "string" || value.length === 0) return null;
22214
+ return value;
22215
+ }
22216
+ 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;
22217
+ var init_cross_agent_chatter_watcher = __esm({
22218
+ "src/sentinel/sentinels/cross-agent-chatter-watcher.ts"() {
22219
+ init_sentinel();
22220
+ CROSS_AGENT_CHATTER_SENTINEL_ID = "cross-agent-chatter";
22221
+ WARN_SIGMA2 = 3;
22222
+ ALERT_SIGMA2 = 6;
22223
+ BASELINE_WINDOWS2 = 7;
22224
+ QUERY_LIMIT2 = 1e4;
22225
+ MULTI_NEW_PARTNER_ALERT_THRESHOLD = 3;
22226
+ OPERATOR_PSEUDO_AGENT = "operator";
22227
+ HANDOFF_OP = "v1.1_local_handoff";
22228
+ CROSS_HARNESS_OPS = /* @__PURE__ */ new Set([
22229
+ "cross_harness_approval_aggregated",
22230
+ "cross_harness_approval_resolved"
22231
+ ]);
22232
+ CrossAgentChatterWatcher = class extends Sentinel {
22233
+ sentinelId = CROSS_AGENT_CHATTER_SENTINEL_ID;
22234
+ 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).";
22235
+ /** Pair keys we have already produced a baseline-established info finding for. */
22236
+ baselineEstablished = /* @__PURE__ */ new Set();
22237
+ async evaluate() {
22238
+ const ctx = this.requireContext();
22239
+ const now = ctx.now();
22240
+ const windowMs = 24 * 60 * 60 * 1e3;
22241
+ const windowSpanMs = (BASELINE_WINDOWS2 + 1) * windowMs;
22242
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22243
+ const queryResult = await ctx.auditLog.query({
22244
+ since: sinceIso,
22245
+ layer: "l2",
22246
+ limit: QUERY_LIMIT2
22247
+ });
22248
+ const events = extractInterAgentEvents(queryResult.entries);
22249
+ const byPair = /* @__PURE__ */ new Map();
22250
+ for (const event of events) {
22251
+ const auditAgeMs = now.getTime() - event.timestampMs;
22252
+ if (auditAgeMs < 0) continue;
22253
+ const windowIdx = Math.floor(auditAgeMs / windowMs);
22254
+ if (windowIdx > BASELINE_WINDOWS2) continue;
22255
+ const key = pairKey(event.sender, event.recipient);
22256
+ let snap = byPair.get(key);
22257
+ if (!snap) {
22258
+ snap = { windows: [] };
22259
+ for (let i = 0; i <= BASELINE_WINDOWS2; i += 1) {
22260
+ snap.windows.push({ count: 0, evidence_audit_ids: [] });
22261
+ }
22262
+ byPair.set(key, snap);
22263
+ }
22264
+ const bucket = snap.windows[windowIdx];
22265
+ bucket.count += 1;
22266
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22267
+ bucket.evidence_audit_ids.push(event.auditId);
22268
+ }
22269
+ }
22270
+ const findings = [];
22271
+ for (const [key, snap] of byPair.entries()) {
22272
+ const finding = this.evaluatePair(key, snap, now);
22273
+ if (finding) findings.push(finding);
22274
+ }
22275
+ const newPartnersBySource = computeNewPartners(byPair);
22276
+ for (const [source, partners] of newPartnersBySource.entries()) {
22277
+ const finding = this.buildNewPartnerFinding(source, partners, now);
22278
+ if (finding) findings.push(finding);
22279
+ }
22280
+ return findings;
22281
+ }
22282
+ /** Reset baseline-established memoization. Tests use this between runs. */
22283
+ resetBaselineMemo() {
22284
+ this.baselineEstablished.clear();
22285
+ }
22286
+ evaluatePair(key, snap, now) {
22287
+ const currentWindow = snap.windows[0];
22288
+ const baselineWindows = snap.windows.slice(1);
22289
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
22290
+ if (populated < BASELINE_WINDOWS2) {
22291
+ return null;
22292
+ }
22293
+ const counts = baselineWindows.map((w) => w.count);
22294
+ const mean = counts.reduce((s, c) => s + c, 0) / counts.length;
22295
+ const variance = counts.reduce((s, c) => s + (c - mean) ** 2, 0) / counts.length;
22296
+ const stddev = Math.sqrt(variance);
22297
+ const wasEstablished = this.baselineEstablished.has(key);
22298
+ this.baselineEstablished.add(key);
22299
+ if (!wasEstablished) {
22300
+ const pair = pairFromKey(key);
22301
+ return {
22302
+ finding_id: "",
22303
+ sentinel_id: this.sentinelId,
22304
+ severity: "info",
22305
+ 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).`,
22306
+ details: {
22307
+ sender_agent_id: pair.sender,
22308
+ recipient_agent_id: pair.recipient,
22309
+ baseline_mean: mean,
22310
+ baseline_stddev: stddev,
22311
+ baseline_windows: counts,
22312
+ current_count: currentWindow.count
22313
+ },
22314
+ observed_at: now.toISOString(),
22315
+ evidence_audit_ids: [],
22316
+ fortress_id: ""
22317
+ };
22318
+ }
22319
+ const warnThreshold = mean + WARN_SIGMA2 * stddev;
22320
+ const alertThreshold = mean + ALERT_SIGMA2 * stddev;
22321
+ if (currentWindow.count > alertThreshold) {
22322
+ return this.buildRateSpike(key, snap, mean, stddev, now, "alert", ALERT_SIGMA2);
22323
+ }
22324
+ if (currentWindow.count > warnThreshold) {
22325
+ return this.buildRateSpike(key, snap, mean, stddev, now, "warn", WARN_SIGMA2);
22326
+ }
22327
+ return null;
22328
+ }
22329
+ buildRateSpike(key, snap, mean, stddev, now, severity, sigma) {
22330
+ const pair = pairFromKey(key);
22331
+ const cur = snap.windows[0];
22332
+ const ratio = mean === 0 ? Infinity : cur.count / mean;
22333
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22334
+ 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.`;
22335
+ return {
22336
+ finding_id: "",
22337
+ sentinel_id: this.sentinelId,
22338
+ severity,
22339
+ summary,
22340
+ details: {
22341
+ sender_agent_id: pair.sender,
22342
+ recipient_agent_id: pair.recipient,
22343
+ current_count: cur.count,
22344
+ baseline_mean: mean,
22345
+ baseline_stddev: stddev,
22346
+ sigma_threshold: sigma,
22347
+ ratio
22348
+ },
22349
+ observed_at: now.toISOString(),
22350
+ agent_id: pair.sender,
22351
+ evidence_audit_ids: cur.evidence_audit_ids,
22352
+ fortress_id: ""
22353
+ };
22354
+ }
22355
+ buildNewPartnerFinding(source, info, now) {
22356
+ if (info.partners.length === 0) return null;
22357
+ const severity = info.partners.length >= MULTI_NEW_PARTNER_ALERT_THRESHOLD ? "alert" : "warn";
22358
+ const partnerList = info.partners.join(", ");
22359
+ const baselinePartnerList = info.priorPartners.length === 0 ? "no prior partners" : info.priorPartners.join(", ");
22360
+ 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}.`;
22361
+ return {
22362
+ finding_id: "",
22363
+ sentinel_id: this.sentinelId,
22364
+ severity,
22365
+ summary,
22366
+ details: {
22367
+ sender_agent_id: source,
22368
+ new_partners: info.partners,
22369
+ prior_partners: info.priorPartners,
22370
+ new_partner_count: info.partners.length,
22371
+ multi_new_partner_threshold: MULTI_NEW_PARTNER_ALERT_THRESHOLD
22372
+ },
22373
+ observed_at: now.toISOString(),
22374
+ agent_id: source,
22375
+ evidence_audit_ids: info.evidenceAuditIds,
22376
+ fortress_id: ""
22377
+ };
22378
+ }
22379
+ };
22380
+ }
22381
+ });
22382
+
22383
+ // src/sentinel/sentinels/credential-usage-watcher.ts
22384
+ function isCredentialAuditEntry(entry) {
22385
+ if (entry.result !== "success") return false;
22386
+ return entry.operation === BROKER_SECRET_READ_OP || entry.operation === BROKER_TOKEN_ISSUED_OP;
22387
+ }
22388
+ function extractAgentId(entry) {
22389
+ const details = entry.details;
22390
+ if (!details) return null;
22391
+ const agent = details["agent"];
22392
+ return typeof agent === "string" && agent.length > 0 ? agent : null;
22393
+ }
22394
+ function extractSecretId(entry) {
22395
+ const details = entry.details;
22396
+ if (!details) return null;
22397
+ const secret = details["secret"];
22398
+ return typeof secret === "string" && secret.length > 0 ? secret : null;
22399
+ }
22400
+ function enumerateUnorderedPairs(secrets) {
22401
+ const out = /* @__PURE__ */ new Set();
22402
+ const arr = [...secrets].sort();
22403
+ for (let i = 0; i < arr.length; i += 1) {
22404
+ for (let j = i + 1; j < arr.length; j += 1) {
22405
+ out.add(`${arr[i]}\0${arr[j]}`);
22406
+ }
22407
+ }
22408
+ return out;
22409
+ }
22410
+ function buildNewPairSummary(agentId, newPairs) {
22411
+ const first = newPairs[0];
22412
+ if (newPairs.length === 1) {
22413
+ return `${agentId} agent used ${first[0]} and ${first[1]} together for the first time today. This combination does not appear in historical sessions.`;
22414
+ }
22415
+ 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.`;
22416
+ }
22417
+ 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;
22418
+ var init_credential_usage_watcher = __esm({
22419
+ "src/sentinel/sentinels/credential-usage-watcher.ts"() {
22420
+ init_sentinel();
22421
+ CREDENTIAL_USAGE_SENTINEL_ID = "credential-usage";
22422
+ WARN_SIGMA3 = 3;
22423
+ ALERT_SIGMA3 = 6;
22424
+ NEW_PAIR_ALERT_COUNT = 3;
22425
+ BASELINE_WINDOWS3 = 7;
22426
+ QUERY_LIMIT3 = 2e4;
22427
+ BROKER_SECRET_READ_OP = "broker_secret_read";
22428
+ BROKER_TOKEN_ISSUED_OP = "broker_token_issued";
22429
+ CredentialUsageWatcher = class extends Sentinel {
22430
+ sentinelId = CREDENTIAL_USAGE_SENTINEL_ID;
22431
+ 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.";
22432
+ /**
22433
+ * Memoization of (agent, secret) pairs whose baseline has been
22434
+ * established. Same shape Phi-1 uses to avoid re-emitting `info`
22435
+ * findings on every tick after a baseline first establishes.
22436
+ *
22437
+ * Phi-2 deliberately does NOT emit `info` findings: per-pair
22438
+ * baselines on a busy fortress would be too noisy. The memo is
22439
+ * kept here for parity with Phi-1's reset hook so tests can clear
22440
+ * state between runs.
22441
+ */
22442
+ baselineEstablished = /* @__PURE__ */ new Set();
22443
+ /** Reset memoization. Tests use this between runs. */
22444
+ resetBaselineMemo() {
22445
+ this.baselineEstablished.clear();
22446
+ }
22447
+ async evaluate() {
22448
+ const ctx = this.requireContext();
22449
+ const now = ctx.now();
22450
+ const windowMs = 24 * 60 * 60 * 1e3;
22451
+ const windowSpanMs = (BASELINE_WINDOWS3 + 1) * windowMs;
22452
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22453
+ const queryResult = await ctx.auditLog.query({
22454
+ since: sinceIso,
22455
+ layer: "l3",
22456
+ limit: QUERY_LIMIT3
22457
+ });
22458
+ const entries = queryResult.entries.filter(isCredentialAuditEntry);
22459
+ const byPair = /* @__PURE__ */ new Map();
22460
+ const byAgent = /* @__PURE__ */ new Map();
22461
+ for (const entry of entries) {
22462
+ const agentId = extractAgentId(entry);
22463
+ const secretId = extractSecretId(entry);
22464
+ if (agentId === null || secretId === null) continue;
22465
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
22466
+ if (auditAge < 0) continue;
22467
+ const windowIdx = Math.floor(auditAge / windowMs);
22468
+ if (windowIdx > BASELINE_WINDOWS3) continue;
22469
+ const pairKey2 = `${agentId}\0${secretId}`;
22470
+ let pairSnapshot = byPair.get(pairKey2);
22471
+ if (!pairSnapshot) {
22472
+ pairSnapshot = {
22473
+ windows: Array.from({ length: BASELINE_WINDOWS3 + 1 }, () => ({
22474
+ count: 0,
22475
+ evidence_audit_ids: []
22476
+ }))
22477
+ };
22478
+ byPair.set(pairKey2, pairSnapshot);
22479
+ }
22480
+ const bucket = pairSnapshot.windows[windowIdx];
22481
+ bucket.count += 1;
22482
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22483
+ bucket.evidence_audit_ids.push(
22484
+ `${entry.timestamp}:${entry.operation}`
22485
+ );
22486
+ }
22487
+ let agentState = byAgent.get(agentId);
22488
+ if (!agentState) {
22489
+ agentState = {
22490
+ currentSecrets: /* @__PURE__ */ new Set(),
22491
+ currentEvidence: [],
22492
+ baselineSecretsByWindow: Array.from(
22493
+ { length: BASELINE_WINDOWS3 },
22494
+ () => /* @__PURE__ */ new Set()
22495
+ ),
22496
+ baselinePopulatedWindows: 0
22497
+ };
22498
+ byAgent.set(agentId, agentState);
22499
+ }
22500
+ if (windowIdx === 0) {
22501
+ agentState.currentSecrets.add(secretId);
22502
+ if (agentState.currentEvidence.length < 50) {
22503
+ agentState.currentEvidence.push(
22504
+ `${entry.timestamp}:${entry.operation}`
22505
+ );
22506
+ }
22507
+ } else {
22508
+ const baselineIdx = windowIdx - 1;
22509
+ agentState.baselineSecretsByWindow[baselineIdx].add(secretId);
22510
+ }
22511
+ }
22512
+ const findings = [];
22513
+ for (const [pairKey2, snapshot] of byPair.entries()) {
22514
+ const [agentId, secretId] = pairKey2.split("\0");
22515
+ const finding = this.evaluateRateSpike(
22516
+ agentId,
22517
+ secretId,
22518
+ snapshot,
22519
+ now
22520
+ );
22521
+ if (finding) findings.push(finding);
22522
+ }
22523
+ for (const [agentId, agentState] of byAgent.entries()) {
22524
+ agentState.baselinePopulatedWindows = agentState.baselineSecretsByWindow.filter((s) => s.size > 0).length;
22525
+ const finding = this.evaluateNewPairs(agentId, agentState, now);
22526
+ if (finding) findings.push(finding);
22527
+ }
22528
+ return findings;
22529
+ }
22530
+ evaluateRateSpike(agentId, secretId, snapshot, now) {
22531
+ const currentWindow = snapshot.windows[0];
22532
+ const baselineWindows = snapshot.windows.slice(1);
22533
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
22534
+ if (populated < BASELINE_WINDOWS3) {
22535
+ return null;
22536
+ }
22537
+ const counts = baselineWindows.map((w) => w.count);
22538
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
22539
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
22540
+ const stddev = Math.sqrt(variance);
22541
+ const pairKey2 = `${agentId}\0${secretId}`;
22542
+ this.baselineEstablished.add(pairKey2);
22543
+ const warnThreshold = mean + WARN_SIGMA3 * stddev;
22544
+ const alertThreshold = mean + ALERT_SIGMA3 * stddev;
22545
+ if (currentWindow.count > alertThreshold) {
22546
+ return this.buildRateFinding(
22547
+ agentId,
22548
+ secretId,
22549
+ currentWindow,
22550
+ mean,
22551
+ stddev,
22552
+ now,
22553
+ "alert",
22554
+ ALERT_SIGMA3
22555
+ );
22556
+ }
22557
+ if (currentWindow.count > warnThreshold) {
22558
+ return this.buildRateFinding(
22559
+ agentId,
22560
+ secretId,
22561
+ currentWindow,
22562
+ mean,
22563
+ stddev,
22564
+ now,
22565
+ "warn",
22566
+ WARN_SIGMA3
22567
+ );
22568
+ }
22569
+ return null;
22570
+ }
22571
+ buildRateFinding(agentId, secretId, currentWindow, mean, stddev, now, severity, sigma) {
22572
+ const ratio = mean === 0 ? Number.POSITIVE_INFINITY : currentWindow.count / mean;
22573
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22574
+ const summary = `${agentId} agent used ${secretId} ${currentWindow.count} times in last 24h, baseline ${mean.toFixed(1)} +/- ${stddev.toFixed(1)} (${ratioStr}). Crossed +${sigma} sigma threshold.`;
22575
+ return {
22576
+ finding_id: "",
22577
+ sentinel_id: this.sentinelId,
22578
+ severity,
22579
+ agent_id: agentId,
22580
+ summary,
22581
+ details: {
22582
+ agent_id: agentId,
22583
+ secret_id: secretId,
22584
+ current_count: currentWindow.count,
22585
+ baseline_mean: mean,
22586
+ baseline_stddev: stddev,
22587
+ sigma_threshold: sigma,
22588
+ ratio: Number.isFinite(ratio) ? ratio : null
22589
+ },
22590
+ observed_at: now.toISOString(),
22591
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
22592
+ fortress_id: ""
22593
+ };
22594
+ }
22595
+ evaluateNewPairs(agentId, state, now) {
22596
+ if (state.baselinePopulatedWindows < BASELINE_WINDOWS3) {
22597
+ return null;
22598
+ }
22599
+ if (state.currentSecrets.size < 2) return null;
22600
+ const currentPairs = enumerateUnorderedPairs(state.currentSecrets);
22601
+ const historicalPairs = /* @__PURE__ */ new Set();
22602
+ for (const secretSet of state.baselineSecretsByWindow) {
22603
+ for (const pair of enumerateUnorderedPairs(secretSet)) {
22604
+ historicalPairs.add(pair);
22605
+ }
22606
+ }
22607
+ const newPairs = [];
22608
+ for (const pair of currentPairs) {
22609
+ if (historicalPairs.has(pair)) continue;
22610
+ const [a, b] = pair.split("\0");
22611
+ newPairs.push([a, b]);
22612
+ }
22613
+ if (newPairs.length === 0) return null;
22614
+ const severity = newPairs.length >= NEW_PAIR_ALERT_COUNT ? "alert" : "warn";
22615
+ const summary = buildNewPairSummary(agentId, newPairs);
22616
+ return {
22617
+ finding_id: "",
22618
+ sentinel_id: this.sentinelId,
22619
+ severity,
22620
+ agent_id: agentId,
22621
+ summary,
22622
+ details: {
22623
+ agent_id: agentId,
22624
+ new_pairs: newPairs,
22625
+ new_pair_count: newPairs.length,
22626
+ historical_pair_count: historicalPairs.size,
22627
+ current_pair_count: currentPairs.size
22628
+ },
22629
+ observed_at: now.toISOString(),
22630
+ evidence_audit_ids: state.currentEvidence,
22631
+ fortress_id: ""
22632
+ };
22633
+ }
22634
+ };
22635
+ }
22636
+ });
22637
+
22638
+ // src/sentinel/sentinels/suspicious-tool-call-detector.ts
22639
+ function countTruncatedValues(args) {
22640
+ let n = 0;
22641
+ for (const v of Object.values(args)) {
22642
+ if (typeof v === "string" && v.endsWith("...")) n += 1;
22643
+ }
22644
+ return n;
22645
+ }
22646
+ function countUrlEncoded(value) {
22647
+ const matches = value.match(/%[0-9a-fA-F]{2}/g);
22648
+ return matches ? matches.length : 0;
22649
+ }
22650
+ function longestBase64Run(value) {
22651
+ const matches = value.match(/[A-Za-z0-9+/=]{40,}/g);
22652
+ if (!matches) return 0;
22653
+ return matches.reduce((max, m) => m.length > max ? m.length : max, 0);
22654
+ }
22655
+ function extractArgsSummary(details) {
22656
+ if (!details) return {};
22657
+ const summary = details["args_summary"];
22658
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
22659
+ return summary;
22660
+ }
22661
+ return {};
22662
+ }
22663
+ function truncateSummary2(s) {
22664
+ return s.length > 240 ? s.slice(0, 237) + "..." : s;
22665
+ }
22666
+ 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;
22667
+ var init_suspicious_tool_call_detector = __esm({
22668
+ "src/sentinel/sentinels/suspicious-tool-call-detector.ts"() {
22669
+ init_sentinel();
22670
+ SUSPICIOUS_TOOL_CALL_SENTINEL_ID = "suspicious-tool-call";
22671
+ GATE_PREFIXES = [
22672
+ "gate_allow:",
22673
+ "gate_allow_proxy:",
22674
+ "gate_deny:",
22675
+ "gate_unclassified:"
22676
+ ];
22677
+ WARN_SIGMA4 = 3;
22678
+ ALERT_SIGMA4 = 6;
22679
+ BASELINE_WINDOWS4 = 7;
22680
+ ALERT_NOVEL_COMBINATIONS = 2;
22681
+ TASK_WINDOW_MS = 60 * 60 * 1e3;
22682
+ TRUNCATION_WARN_THRESHOLD = 5;
22683
+ QUERY_LIMIT4 = 1e4;
22684
+ SIGNATURE_PATTERNS = {
22685
+ /** >=5 percent-encoded sequences in a single visible value. */
22686
+ urlEncodedThreshold: 5,
22687
+ /** >=40 contiguous base64 chars in a single visible value. */
22688
+ base64MinRun: 40,
22689
+ /** Shell metacharacter set. */
22690
+ shellMetacharRegex: /(?:&&|\|\||;|\$\(|`|\|\s)/
22691
+ };
22692
+ SuspiciousToolCallDetector = class extends Sentinel {
22693
+ sentinelId = SUSPICIOUS_TOOL_CALL_SENTINEL_ID;
22694
+ 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.";
22695
+ /** Servers we have already produced an `info` baseline-established finding for. */
22696
+ baselineEstablished = /* @__PURE__ */ new Set();
22697
+ /** Memoized known novel-combination keys (sorted-tools-csv). */
22698
+ knownCombinations = /* @__PURE__ */ new Set();
22699
+ /** Tasks observed where a novel combination already produced a finding. */
22700
+ novelCombinationsReported = /* @__PURE__ */ new Set();
22701
+ async evaluate() {
22702
+ const ctx = this.requireContext();
22703
+ const now = ctx.now();
22704
+ const dayMs = 24 * 60 * 60 * 1e3;
22705
+ const windowSpanMs = (BASELINE_WINDOWS4 + 1) * dayMs;
22706
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22707
+ const queryResult = await ctx.auditLog.query({
22708
+ since: sinceIso,
22709
+ layer: "l2",
22710
+ limit: QUERY_LIMIT4
22711
+ });
22712
+ const observations = [];
22713
+ for (const entry of queryResult.entries) {
22714
+ const obs = this.observationFromEntry(entry);
22715
+ if (obs && obs.ts <= now.getTime()) observations.push(obs);
22716
+ }
22717
+ if (observations.length === 0) return [];
22718
+ const findings = [];
22719
+ const layerAFindings = await this.runLayerA(observations, now, ctx);
22720
+ findings.push(...layerAFindings);
22721
+ const layerBFindings = this.runLayerB(observations, now);
22722
+ findings.push(...layerBFindings);
22723
+ const layerCFindings = this.runLayerC(observations, now);
22724
+ findings.push(...layerCFindings);
22725
+ return findings;
22726
+ }
22727
+ /** Reset memoization between test runs. Mirrors Phi-1's reset hook. */
22728
+ resetMemo() {
22729
+ this.baselineEstablished.clear();
22730
+ this.knownCombinations.clear();
22731
+ this.novelCombinationsReported.clear();
22732
+ }
22733
+ // ── Layer A ───────────────────────────────────────────────────────
22734
+ async runLayerA(observations, now, ctx) {
22735
+ const dayMs = 24 * 60 * 60 * 1e3;
22736
+ const recent = observations.filter(
22737
+ (o) => now.getTime() - o.ts <= dayMs
22738
+ );
22739
+ if (recent.length === 0) return [];
22740
+ const findings = [];
22741
+ const historical = observations.filter(
22742
+ (o) => now.getTime() - o.ts > dayMs
22743
+ );
22744
+ const perTool = /* @__PURE__ */ new Map();
22745
+ const ensureTool = (tool) => {
22746
+ let w = perTool.get(tool);
22747
+ if (!w) {
22748
+ w = {
22749
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
22750
+ count: 0,
22751
+ evidenceIds: []
22752
+ })),
22753
+ knownSignatures: /* @__PURE__ */ new Set()
22754
+ };
22755
+ perTool.set(tool, w);
22756
+ }
22757
+ return w;
22758
+ };
22759
+ for (const o of historical) {
22760
+ ensureTool(o.tool).knownSignatures.add(this.signatureOf(o.argsSummary));
22761
+ }
22762
+ const classify = this.classifyHandle(ctx);
22763
+ for (const obs of recent) {
22764
+ const matches = this.matchSignatures(obs);
22765
+ if (matches.length === 0) continue;
22766
+ const ambiguous = matches.every((m) => m === "base64_chunk");
22767
+ if (ambiguous && classify) {
22768
+ const verdict = await this.consultClassifier(classify, obs);
22769
+ if (verdict !== "suspicious") continue;
22770
+ }
22771
+ findings.push(
22772
+ this.buildLayerAFinding(obs, matches, now, classify ? "llm-assist" : "rule-based")
22773
+ );
22774
+ }
22775
+ for (const obs of recent) {
22776
+ const tool = obs.tool;
22777
+ const sig = this.signatureOf(obs.argsSummary);
22778
+ const known = perTool.get(tool)?.knownSignatures;
22779
+ if (known && known.size > 0 && !known.has(sig)) {
22780
+ findings.push(
22781
+ this.buildNovelSignatureFinding(obs, sig, now)
22782
+ );
22783
+ }
22784
+ }
22785
+ return findings;
22786
+ }
22787
+ matchSignatures(obs) {
22788
+ const out = [];
22789
+ const truncCount = countTruncatedValues(obs.argsSummary);
22790
+ if (truncCount >= TRUNCATION_WARN_THRESHOLD) out.push("truncation_burst");
22791
+ let urlBlob = false;
22792
+ let base64Blob = false;
22793
+ let shellChars = false;
22794
+ for (const value of Object.values(obs.argsSummary)) {
22795
+ if (typeof value !== "string") continue;
22796
+ if (countUrlEncoded(value) >= SIGNATURE_PATTERNS.urlEncodedThreshold) {
22797
+ urlBlob = true;
22798
+ }
22799
+ if (longestBase64Run(value) >= SIGNATURE_PATTERNS.base64MinRun) {
22800
+ base64Blob = true;
22801
+ }
22802
+ if (SIGNATURE_PATTERNS.shellMetacharRegex.test(value)) {
22803
+ shellChars = true;
22804
+ }
22805
+ }
22806
+ if (urlBlob) out.push("url_encoded_blob");
22807
+ if (base64Blob) out.push("base64_chunk");
22808
+ if (shellChars) out.push("shell_metachar");
22809
+ return out;
22810
+ }
22811
+ signatureOf(argsSummary) {
22812
+ return Object.keys(argsSummary).sort().join(",");
22813
+ }
22814
+ buildLayerAFinding(obs, matches, now, detectionPath) {
22815
+ const severity = matches.includes("shell_metachar") ? "alert" : "warn";
22816
+ const summary = `${obs.tool}: tool-call argument matches signature ${matches.join(", ")} (${detectionPath}).`;
22817
+ return {
22818
+ finding_id: "",
22819
+ sentinel_id: this.sentinelId,
22820
+ severity,
22821
+ summary: truncateSummary2(summary),
22822
+ details: {
22823
+ layer: "A",
22824
+ tool: obs.tool,
22825
+ proxy: obs.proxy,
22826
+ signatures: matches,
22827
+ detection_path: detectionPath
22828
+ },
22829
+ observed_at: now.toISOString(),
22830
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
22831
+ fortress_id: ""
22832
+ };
22833
+ }
22834
+ buildNovelSignatureFinding(obs, signature, now) {
22835
+ return {
22836
+ finding_id: "",
22837
+ sentinel_id: this.sentinelId,
22838
+ severity: "warn",
22839
+ summary: truncateSummary2(
22840
+ `${obs.tool}: novel argument-key signature observed (${signature || "<no-args>"}).`
22841
+ ),
22842
+ details: {
22843
+ layer: "A",
22844
+ tool: obs.tool,
22845
+ proxy: obs.proxy,
22846
+ signatures: ["novel_signature"],
22847
+ detection_path: "rule-based",
22848
+ novel_signature: signature
22849
+ },
22850
+ observed_at: now.toISOString(),
22851
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
22852
+ fortress_id: ""
22853
+ };
22854
+ }
22855
+ // ── Layer B ───────────────────────────────────────────────────────
22856
+ runLayerB(observations, now) {
22857
+ const dayMs = 24 * 60 * 60 * 1e3;
22858
+ const perTool = /* @__PURE__ */ new Map();
22859
+ for (const obs of observations) {
22860
+ const ageMs = now.getTime() - obs.ts;
22861
+ if (ageMs < 0) continue;
22862
+ const windowIdx = Math.floor(ageMs / dayMs);
22863
+ if (windowIdx > BASELINE_WINDOWS4) continue;
22864
+ let w = perTool.get(obs.tool);
22865
+ if (!w) {
22866
+ w = {
22867
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
22868
+ count: 0,
22869
+ evidenceIds: []
22870
+ })),
22871
+ knownSignatures: /* @__PURE__ */ new Set()
22872
+ };
22873
+ perTool.set(obs.tool, w);
22874
+ }
22875
+ const bucket = w.windows[windowIdx];
22876
+ bucket.count += 1;
22877
+ if (windowIdx === 0 && bucket.evidenceIds.length < 50) {
22878
+ bucket.evidenceIds.push(`${obs.entry.timestamp}:${obs.entry.operation}`);
22879
+ }
22880
+ }
22881
+ const findings = [];
22882
+ for (const [tool, w] of perTool.entries()) {
22883
+ const f = this.evaluateToolFrequency(tool, w, now);
22884
+ if (f) findings.push(f);
22885
+ }
22886
+ return findings;
22887
+ }
22888
+ evaluateToolFrequency(tool, w, now) {
22889
+ const current = w.windows[0];
22890
+ const baseline = w.windows.slice(1);
22891
+ const populated = baseline.filter((b) => b.count > 0).length;
22892
+ if (populated < BASELINE_WINDOWS4) {
22893
+ this.baselineEstablished.add(tool);
22894
+ return null;
22895
+ }
22896
+ const counts = baseline.map((b) => b.count);
22897
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
22898
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
22899
+ const stddev = Math.sqrt(variance);
22900
+ const wasEstablished = this.baselineEstablished.has(tool);
22901
+ this.baselineEstablished.add(tool);
22902
+ if (!wasEstablished) {
22903
+ return {
22904
+ finding_id: "",
22905
+ sentinel_id: this.sentinelId,
22906
+ severity: "info",
22907
+ summary: truncateSummary2(
22908
+ `${tool}: tool-call baseline established (mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} over ${BASELINE_WINDOWS4} prior days).`
22909
+ ),
22910
+ details: {
22911
+ layer: "B",
22912
+ tool,
22913
+ baseline_mean: mean,
22914
+ baseline_stddev: stddev,
22915
+ baseline_counts: counts,
22916
+ current_count: current.count
22917
+ },
22918
+ observed_at: now.toISOString(),
22919
+ evidence_audit_ids: [],
22920
+ fortress_id: ""
22921
+ };
22922
+ }
22923
+ const warnT = mean + WARN_SIGMA4 * stddev;
22924
+ const alertT = mean + ALERT_SIGMA4 * stddev;
22925
+ if (current.count > alertT) {
22926
+ return this.buildLayerBAnomaly(
22927
+ tool,
22928
+ current,
22929
+ mean,
22930
+ stddev,
22931
+ ALERT_SIGMA4,
22932
+ "alert",
22933
+ now
22934
+ );
22935
+ }
22936
+ if (current.count > warnT) {
22937
+ return this.buildLayerBAnomaly(
22938
+ tool,
22939
+ current,
22940
+ mean,
22941
+ stddev,
22942
+ WARN_SIGMA4,
22943
+ "warn",
22944
+ now
22945
+ );
22946
+ }
22947
+ return null;
22948
+ }
22949
+ buildLayerBAnomaly(tool, current, mean, stddev, sigma, severity, now) {
22950
+ const ratio = mean === 0 ? Infinity : current.count / mean;
22951
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22952
+ return {
22953
+ finding_id: "",
22954
+ sentinel_id: this.sentinelId,
22955
+ severity,
22956
+ summary: truncateSummary2(
22957
+ `${tool}: tool-call rate is ${ratioStr}: ${current.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma.`
22958
+ ),
22959
+ details: {
22960
+ layer: "B",
22961
+ tool,
22962
+ current_count: current.count,
22963
+ baseline_mean: mean,
22964
+ baseline_stddev: stddev,
22965
+ sigma_threshold: sigma,
22966
+ ratio
22967
+ },
22968
+ observed_at: now.toISOString(),
22969
+ evidence_audit_ids: current.evidenceIds,
22970
+ fortress_id: ""
22971
+ };
22972
+ }
22973
+ // ── Layer C ───────────────────────────────────────────────────────
22974
+ runLayerC(observations, now) {
22975
+ const dayMs = 24 * 60 * 60 * 1e3;
22976
+ const sorted = [...observations].sort((a, b) => a.ts - b.ts);
22977
+ const tasks = [];
22978
+ for (const obs of sorted) {
22979
+ const last = tasks[tasks.length - 1];
22980
+ if (!last || obs.ts - last.startTs > TASK_WINDOW_MS) {
22981
+ tasks.push({ startTs: obs.ts, tools: [obs.tool] });
22982
+ continue;
22983
+ }
22984
+ if (!last.tools.includes(obs.tool)) last.tools.push(obs.tool);
22985
+ }
22986
+ const recentTaskKeys = [];
22987
+ const findings = [];
22988
+ for (const task of tasks) {
22989
+ const ageMs = now.getTime() - task.startTs;
22990
+ const key = task.tools.slice().sort().join(",");
22991
+ if (ageMs > dayMs) {
22992
+ this.knownCombinations.add(key);
22993
+ continue;
22994
+ }
22995
+ if (task.tools.length < 2) continue;
22996
+ if (!this.knownCombinations.has(key)) {
22997
+ this.knownCombinations.add(key);
22998
+ if (!this.novelCombinationsReported.has(key)) {
22999
+ this.novelCombinationsReported.add(key);
23000
+ recentTaskKeys.push(key);
23001
+ findings.push(
23002
+ this.buildLayerCFinding(task, key, "warn", now)
23003
+ );
23004
+ }
23005
+ }
23006
+ }
23007
+ if (recentTaskKeys.length >= ALERT_NOVEL_COMBINATIONS) {
23008
+ const aggregate = {
23009
+ finding_id: "",
23010
+ sentinel_id: this.sentinelId,
23011
+ severity: "alert",
23012
+ summary: truncateSummary2(
23013
+ `multi-novel-combination: ${recentTaskKeys.length} novel tool-permission combinations within last 24h.`
23014
+ ),
23015
+ details: {
23016
+ layer: "C",
23017
+ novel_combinations: recentTaskKeys
23018
+ },
23019
+ observed_at: now.toISOString(),
23020
+ evidence_audit_ids: [],
23021
+ fortress_id: ""
23022
+ };
23023
+ findings.push(aggregate);
23024
+ }
23025
+ return findings;
23026
+ }
23027
+ buildLayerCFinding(task, key, severity, now) {
23028
+ return {
23029
+ finding_id: "",
23030
+ sentinel_id: this.sentinelId,
23031
+ severity,
23032
+ summary: truncateSummary2(
23033
+ `novel-permission-combination: tools=[${task.tools.join(",")}] observed in single task burst (${task.tools.length} distinct tools).`
23034
+ ),
23035
+ details: {
23036
+ layer: "C",
23037
+ combination_key: key,
23038
+ tools: task.tools,
23039
+ task_started_at: new Date(task.startTs).toISOString()
23040
+ },
23041
+ observed_at: now.toISOString(),
23042
+ evidence_audit_ids: [],
23043
+ fortress_id: ""
23044
+ };
23045
+ }
23046
+ // ── LLM-assist ────────────────────────────────────────────────────
23047
+ classifyHandle(ctx) {
23048
+ const selector = ctx.substrateSelector;
23049
+ if (!selector) return null;
23050
+ const fn = selector.invokeClassify;
23051
+ if (typeof fn !== "function") return null;
23052
+ return async (items) => {
23053
+ try {
23054
+ const resp = await fn.call(selector, "sentinel-scoring", {
23055
+ kind: "classify",
23056
+ items,
23057
+ categories: ["benign", "suspicious"]
23058
+ });
23059
+ if (resp.failureClass) return { kind: "failure", message: "substrate failure" };
23060
+ if (resp.body.kind === "classify") {
23061
+ return { kind: "classify", results: resp.body.results };
23062
+ }
23063
+ return { kind: "failure", message: resp.body.message };
23064
+ } catch {
23065
+ return null;
23066
+ }
23067
+ };
23068
+ }
23069
+ async consultClassifier(classify, obs) {
23070
+ const item = JSON.stringify({
23071
+ tool: obs.tool,
23072
+ proxy: obs.proxy,
23073
+ args_summary: obs.argsSummary
23074
+ });
23075
+ const result = await classify([item]);
23076
+ if (!result || result.kind !== "classify") return "unknown";
23077
+ const top = result.results[0];
23078
+ if (!top) return "unknown";
23079
+ if (top.category === "suspicious" && top.confidence >= 0.5) {
23080
+ return "suspicious";
23081
+ }
23082
+ if (top.category === "benign") return "benign";
23083
+ return "unknown";
23084
+ }
23085
+ // ── helpers ───────────────────────────────────────────────────────
23086
+ observationFromEntry(entry) {
23087
+ const op = entry.operation;
23088
+ let tool = null;
23089
+ let proxy = false;
23090
+ for (const prefix of GATE_PREFIXES) {
23091
+ if (op.startsWith(prefix)) {
23092
+ tool = op.slice(prefix.length);
23093
+ proxy = prefix === "gate_allow_proxy:";
23094
+ break;
23095
+ }
23096
+ }
23097
+ if (!tool) return null;
23098
+ const ts = Date.parse(entry.timestamp);
23099
+ if (!Number.isFinite(ts)) return null;
23100
+ const argsSummary = extractArgsSummary(entry.details);
23101
+ return { tool, proxy, ts, entry, argsSummary };
23102
+ }
23103
+ };
23104
+ }
23105
+ });
23106
+
23107
+ // src/sentinel/sentinels/index.ts
23108
+ var PHI1_BASELINE_CATALOG;
23109
+ var init_sentinels = __esm({
23110
+ "src/sentinel/sentinels/index.ts"() {
23111
+ init_egress_volume_watcher();
23112
+ init_cross_agent_chatter_watcher();
23113
+ init_credential_usage_watcher();
23114
+ init_suspicious_tool_call_detector();
23115
+ PHI1_BASELINE_CATALOG = [
23116
+ {
23117
+ sentinelId: EGRESS_VOLUME_SENTINEL_ID,
23118
+ description: "Watches outbound proxy-call volume per upstream server and surfaces anomalous spikes against a rolling 7-day baseline.",
23119
+ factory: () => new EgressVolumeWatcher()
23120
+ },
23121
+ {
23122
+ sentinelId: CROSS_AGENT_CHATTER_SENTINEL_ID,
23123
+ 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.",
23124
+ factory: () => new CrossAgentChatterWatcher()
23125
+ },
23126
+ {
23127
+ sentinelId: CREDENTIAL_USAGE_SENTINEL_ID,
23128
+ 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.",
23129
+ factory: () => new CredentialUsageWatcher()
23130
+ },
23131
+ {
23132
+ sentinelId: SUSPICIOUS_TOOL_CALL_SENTINEL_ID,
23133
+ description: "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history.",
23134
+ factory: () => new SuspiciousToolCallDetector()
23135
+ }
23136
+ ];
23137
+ }
23138
+ });
23139
+ function sentinelSubscriptionsPath(storagePath) {
23140
+ return path.join(storagePath, "sentinel-subscriptions.json");
23141
+ }
23142
+ async function loadSentinelSubscriptions(storagePath) {
23143
+ const filePath = sentinelSubscriptionsPath(storagePath);
23144
+ try {
23145
+ const raw = await promises.readFile(filePath, "utf8");
23146
+ const parsed = JSON.parse(raw);
23147
+ if (parsed.version !== FILE_VERSION) return /* @__PURE__ */ new Set();
23148
+ if (!Array.isArray(parsed.subscribed)) return /* @__PURE__ */ new Set();
23149
+ const cleaned = parsed.subscribed.filter(
23150
+ (id) => typeof id === "string" && id.length > 0
23151
+ );
23152
+ return new Set(cleaned);
23153
+ } catch {
23154
+ return /* @__PURE__ */ new Set();
23155
+ }
23156
+ }
23157
+ async function saveSentinelSubscriptions(storagePath, subscribed) {
23158
+ const filePath = sentinelSubscriptionsPath(storagePath);
23159
+ await promises.mkdir(path.dirname(filePath), { recursive: true });
23160
+ const payload = {
23161
+ version: FILE_VERSION,
23162
+ subscribed: [...new Set(subscribed)].filter((s) => s.length > 0).sort()
23163
+ };
23164
+ await promises.writeFile(filePath, `${JSON.stringify(payload, null, 2)}
23165
+ `, {
23166
+ mode: 384
23167
+ });
23168
+ }
23169
+ var FILE_VERSION;
23170
+ var init_subscription_store = __esm({
23171
+ "src/sentinel/subscription-store.ts"() {
23172
+ FILE_VERSION = 1;
23173
+ }
23174
+ });
23175
+
21141
23176
  // src/principal-policy/tools.ts
21142
23177
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
21143
23178
  return [
@@ -32615,7 +34650,7 @@ var init_recovery_key_disclosure = __esm({
32615
34650
  });
32616
34651
 
32617
34652
  // src/hub/types.ts
32618
- var init_types3 = __esm({
34653
+ var init_types4 = __esm({
32619
34654
  "src/hub/types.ts"() {
32620
34655
  }
32621
34656
  });
@@ -33654,7 +35689,7 @@ var init_hub = __esm({
33654
35689
  "src/hub/index.ts"() {
33655
35690
  init_constants3();
33656
35691
  init_errors4();
33657
- init_types3();
35692
+ init_types4();
33658
35693
  init_agent_registry();
33659
35694
  init_inbox_store();
33660
35695
  init_inbox_aggregator();
@@ -33763,7 +35798,17 @@ var init_operator_chat_audit_events = __esm({
33763
35798
  * fold. The concierge omits that category and continues; the user-
33764
35799
  * facing query is never broken. Body carries category + failure_reason.
33765
35800
  */
33766
- CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed"
35801
+ CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed",
35802
+ /**
35803
+ * Concierge surfaced a proactive starter when a fresh conversation
35804
+ * thread opened (WP-V1.3-9 Tau-5). Emitted once per starter, never on
35805
+ * follow-up turns within the same thread. Body carries `thread_id`,
35806
+ * `trigger` (stable enum), and `triggered_agents_count`. The starter
35807
+ * text body is NOT carried; the trigger enum is sufficient for
35808
+ * dashboard grouping and keeps fortress-internal agent ids off the
35809
+ * audit surface.
35810
+ */
35811
+ CONCIERGE_PROACTIVE_SUGGESTION_OFFERED: "operator_concierge_proactive_suggestion_offered"
33767
35812
  };
33768
35813
  }
33769
35814
  });
@@ -33796,9 +35841,10 @@ function isTrivialQuery(query) {
33796
35841
  if (norm.length < 8) return true;
33797
35842
  return TRIVIAL_GREETINGS.has(norm);
33798
35843
  }
33799
- function classifyQuery(query) {
35844
+ function classifyQuery(query, parsedGrammar) {
33800
35845
  const normalized = query.toLowerCase();
33801
35846
  const matches = [];
35847
+ const grammarAgent = parsedGrammar && parsedGrammar.agent_names.length > 0 ? parsedGrammar.agent_names[0] ?? null : null;
33802
35848
  for (const spec of CATEGORY_KEYWORDS) {
33803
35849
  const matchedPhrases = [];
33804
35850
  for (const pattern of spec.patterns) {
@@ -33810,11 +35856,14 @@ function classifyQuery(query) {
33810
35856
  }
33811
35857
  if (matchedPhrases.length === 0) continue;
33812
35858
  const confidence = Math.min(1, 0.4 + 0.3 * matchedPhrases.length);
35859
+ const wantsAgentHint = spec.category === "agent_state" || spec.category === "agent_activity";
35860
+ const agent_name_hint = wantsAgentHint ? grammarAgent ?? extractAgentNameHint(query) : null;
33813
35861
  matches.push({
33814
35862
  category: spec.category,
33815
35863
  confidence,
33816
35864
  matched_keywords: matchedPhrases,
33817
- agent_name_hint: spec.category === "agent_state" || spec.category === "agent_activity" ? extractAgentNameHint(query) : null
35865
+ agent_name_hint,
35866
+ ...parsedGrammar !== void 0 ? { query_grammar: parsedGrammar } : {}
33818
35867
  });
33819
35868
  }
33820
35869
  matches.sort((a, b) => {
@@ -33823,45 +35872,67 @@ function classifyQuery(query) {
33823
35872
  });
33824
35873
  return matches;
33825
35874
  }
35875
+ function fetcherHintsFromGrammar(parsed) {
35876
+ if (!parsed) return void 0;
35877
+ const hasTime = parsed.time_range !== null;
35878
+ const hasAgents = parsed.agent_names.length > 0;
35879
+ const hasEvents = parsed.event_types.length > 0;
35880
+ if (!hasTime && !hasAgents && !hasEvents) return void 0;
35881
+ const hints = {};
35882
+ if (parsed.time_range) {
35883
+ const range = parsed.time_range;
35884
+ hints.time_range = {
35885
+ start: range.start,
35886
+ end: range.end,
35887
+ ...range.relative_label !== void 0 ? { relative_label: range.relative_label } : {}
35888
+ };
35889
+ }
35890
+ if (hasAgents) hints.agent_names = parsed.agent_names;
35891
+ if (hasEvents) hints.event_types = parsed.event_types;
35892
+ return hints;
35893
+ }
33826
35894
  function approxTokenLen(text) {
33827
35895
  return Math.ceil(text.length / APPROX_CHARS_PER_TOKEN);
33828
35896
  }
33829
- async function runFetcher(match, fetchers) {
35897
+ async function runFetcher(match, fetchers, hints) {
33830
35898
  switch (match.category) {
33831
35899
  case "templates":
33832
- return fetchers.templates();
35900
+ return fetchers.templates(hints);
33833
35901
  case "agent_state":
33834
- return fetchers.agent_state(match.agent_name_hint);
35902
+ return fetchers.agent_state(match.agent_name_hint, hints);
33835
35903
  case "agent_activity":
33836
- return fetchers.agent_activity(match.agent_name_hint);
35904
+ return fetchers.agent_activity(match.agent_name_hint, hints);
33837
35905
  case "audit_log":
33838
- return fetchers.audit_log();
35906
+ return fetchers.audit_log(hints);
33839
35907
  case "sentinel_findings":
33840
- return fetchers.sentinel_findings();
35908
+ return fetchers.sentinel_findings(hints);
33841
35909
  case "anomaly_alerts":
33842
- return fetchers.anomaly_alerts();
35910
+ return fetchers.anomaly_alerts(hints);
33843
35911
  case "recent_receipts":
33844
- return fetchers.recent_receipts();
35912
+ return fetchers.recent_receipts(hints);
33845
35913
  case "verascore_deltas":
33846
- return fetchers.verascore_deltas();
35914
+ return fetchers.verascore_deltas(hints);
33847
35915
  }
33848
35916
  }
33849
- function trivialMatch(category) {
35917
+ function trivialMatch(category, parsedGrammar) {
33850
35918
  return {
33851
35919
  category,
33852
35920
  confidence: 0.5,
33853
35921
  matched_keywords: ["llm-assist"],
33854
- agent_name_hint: null
35922
+ agent_name_hint: parsedGrammar && parsedGrammar.agent_names.length > 0 ? parsedGrammar.agent_names[0] ?? null : null,
35923
+ ...parsedGrammar !== void 0 ? { query_grammar: parsedGrammar } : {}
33855
35924
  };
33856
35925
  }
33857
35926
  async function foldContext(query, fetchers, opts) {
33858
35927
  const budget = opts?.maxTokens ?? DEFAULT_DYNAMIC_CONTEXT_TOKEN_BUDGET;
33859
- let matches = classifyQuery(query);
35928
+ const parsed = opts?.parsed ?? null;
35929
+ const hints = fetcherHintsFromGrammar(parsed);
35930
+ let matches = classifyQuery(query, parsed);
33860
35931
  if (matches.length === 0 && !isTrivialQuery(query) && opts?.llmAssistClassify) {
33861
35932
  try {
33862
35933
  const picked = await opts.llmAssistClassify(query, CONTEXT_CATEGORIES);
33863
35934
  if (picked !== "none" && CONTEXT_CATEGORIES.includes(picked)) {
33864
- matches = [trivialMatch(picked)];
35935
+ matches = [trivialMatch(picked, parsed)];
33865
35936
  }
33866
35937
  } catch {
33867
35938
  }
@@ -33872,7 +35943,7 @@ async function foldContext(query, fetchers, opts) {
33872
35943
  const attempts = [];
33873
35944
  for (const match of matches) {
33874
35945
  try {
33875
- const text = await runFetcher(match, fetchers);
35946
+ const text = await runFetcher(match, fetchers, hints);
33876
35947
  const trimmed = text.trim();
33877
35948
  if (trimmed.length > 0) {
33878
35949
  attempts.push({ category: match.category, text: trimmed });
@@ -34044,9 +36115,635 @@ var init_concierge_context_router = __esm({
34044
36115
  };
34045
36116
  }
34046
36117
  });
36118
+
36119
+ // src/composition/constants.ts
36120
+ var COMPOSITION_EVENT_TYPES;
36121
+ var init_constants4 = __esm({
36122
+ "src/composition/constants.ts"() {
36123
+ init_constants();
36124
+ COMPOSITION_EVENT_TYPES = [
36125
+ "composition_receipt_packed",
36126
+ "composition_receipt_verified",
36127
+ "composition_mandate_verified",
36128
+ "composition_verascore_published",
36129
+ "composition_sidecar_spawned",
36130
+ "composition_sidecar_crashed",
36131
+ "composition_sidecar_recovered",
36132
+ "composition_degraded",
36133
+ "composition_recovered"
36134
+ ];
36135
+ }
36136
+ });
36137
+
36138
+ // src/chat/concierge-query-grammar.ts
36139
+ function resolveTimeRange(query, now) {
36140
+ const normalized = query.trim();
36141
+ const lower = normalized.toLowerCase();
36142
+ const fromTo = lower.match(
36143
+ /\b(?:from|between)\s+(.+?)\s+(?:to|and|-|until)\s+([\w:.\-+t /]+)/i
36144
+ );
36145
+ if (fromTo) {
36146
+ const aSlice = fromTo[1];
36147
+ const bSlice = fromTo[2];
36148
+ if (aSlice !== void 0 && bSlice !== void 0) {
36149
+ const a = parseInstant(aSlice, now);
36150
+ const b = parseInstant(bSlice, now);
36151
+ if (a && b) {
36152
+ const start = a.getTime() <= b.getTime() ? a : b;
36153
+ const end = a.getTime() <= b.getTime() ? b : a;
36154
+ return {
36155
+ range: { start, end },
36156
+ matchedSubstring: fromTo[0]
36157
+ };
36158
+ }
36159
+ }
36160
+ }
36161
+ const sinceMatch = lower.match(/\bsince\s+([\w:.\-+t /]+)/i);
36162
+ if (sinceMatch) {
36163
+ const slice = sinceMatch[1];
36164
+ if (slice !== void 0) {
36165
+ const start = parseInstant(slice, now);
36166
+ if (start) {
36167
+ return {
36168
+ range: { start, end: now },
36169
+ matchedSubstring: sinceMatch[0]
36170
+ };
36171
+ }
36172
+ }
36173
+ }
36174
+ if (/\byesterday\b/.test(lower)) {
36175
+ const startOfToday = startOfDay(now);
36176
+ const start = new Date(startOfToday.getTime() - MS_PER_DAY);
36177
+ const end = new Date(startOfToday.getTime() - 1);
36178
+ return {
36179
+ range: { start, end, relative_label: "yesterday" },
36180
+ matchedSubstring: "yesterday"
36181
+ };
36182
+ }
36183
+ if (/\btoday\b/.test(lower)) {
36184
+ return {
36185
+ range: {
36186
+ start: startOfDay(now),
36187
+ end: now,
36188
+ relative_label: "today"
36189
+ },
36190
+ matchedSubstring: "today"
36191
+ };
36192
+ }
36193
+ const compactHours = lower.match(/\blast\s+(\d+)\s*h\b/i);
36194
+ if (compactHours) {
36195
+ const tok = compactHours[1];
36196
+ if (tok !== void 0) {
36197
+ const n = Number.parseInt(tok, 10);
36198
+ if (Number.isFinite(n) && n > 0) {
36199
+ const start = new Date(now.getTime() - n * MS_PER_HOUR);
36200
+ return {
36201
+ range: { start, end: now, relative_label: `last ${n}h` },
36202
+ matchedSubstring: compactHours[0]
36203
+ };
36204
+ }
36205
+ }
36206
+ }
36207
+ const hoursMatch = lower.match(
36208
+ /\b(?:past|last)\s+([\w]+|\d+)\s*(?:hr\b|hrs\b|hour|hours)/i
36209
+ );
36210
+ if (hoursMatch) {
36211
+ const tok = hoursMatch[1];
36212
+ if (tok !== void 0) {
36213
+ const n = parseCount(tok);
36214
+ if (n !== null && n > 0) {
36215
+ const start = new Date(now.getTime() - n * MS_PER_HOUR);
36216
+ return {
36217
+ range: { start, end: now, relative_label: `past ${n} hour${n === 1 ? "" : "s"}` },
36218
+ matchedSubstring: hoursMatch[0]
36219
+ };
36220
+ }
36221
+ }
36222
+ }
36223
+ if (/\b(?:past|last)\s+hour\b/.test(lower)) {
36224
+ const start = new Date(now.getTime() - MS_PER_HOUR);
36225
+ return {
36226
+ range: { start, end: now, relative_label: "past hour" },
36227
+ matchedSubstring: lower.match(/\b(?:past|last)\s+hour\b/i)[0]
36228
+ };
36229
+ }
36230
+ const daysMatch = lower.match(
36231
+ /\b(?:past|last)\s+([\w]+|\d+)\s*(?:d\b|day|days)/i
36232
+ );
36233
+ if (daysMatch) {
36234
+ const tok = daysMatch[1];
36235
+ if (tok !== void 0) {
36236
+ const n = parseCount(tok);
36237
+ if (n !== null && n > 0) {
36238
+ const start = new Date(now.getTime() - n * MS_PER_DAY);
36239
+ return {
36240
+ range: { start, end: now, relative_label: `past ${n} day${n === 1 ? "" : "s"}` },
36241
+ matchedSubstring: daysMatch[0]
36242
+ };
36243
+ }
36244
+ }
36245
+ }
36246
+ if (/\b(?:past|last)\s+day\b/.test(lower)) {
36247
+ const start = new Date(now.getTime() - MS_PER_DAY);
36248
+ return {
36249
+ range: { start, end: now, relative_label: "past day" },
36250
+ matchedSubstring: lower.match(/\b(?:past|last)\s+day\b/i)[0]
36251
+ };
36252
+ }
36253
+ if (/\bthis\s+week\b/.test(lower)) {
36254
+ const start = startOfWeek(now);
36255
+ return {
36256
+ range: { start, end: now, relative_label: "this week" },
36257
+ matchedSubstring: lower.match(/\bthis\s+week\b/i)[0]
36258
+ };
36259
+ }
36260
+ if (/\b(?:past|last)\s+week\b/.test(lower)) {
36261
+ const start = new Date(now.getTime() - 7 * MS_PER_DAY);
36262
+ return {
36263
+ range: { start, end: now, relative_label: "past week" },
36264
+ matchedSubstring: lower.match(/\b(?:past|last)\s+week\b/i)[0]
36265
+ };
36266
+ }
36267
+ const isoMatch = normalized.match(
36268
+ /\b(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?)?)\b/
36269
+ );
36270
+ if (isoMatch) {
36271
+ const tok = isoMatch[1];
36272
+ if (tok !== void 0) {
36273
+ const parsed = parseInstant(tok, now);
36274
+ if (parsed) {
36275
+ const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(tok);
36276
+ if (isDateOnly) {
36277
+ return {
36278
+ range: {
36279
+ start: parsed,
36280
+ end: new Date(parsed.getTime() + MS_PER_DAY - 1)
36281
+ },
36282
+ matchedSubstring: tok
36283
+ };
36284
+ }
36285
+ return {
36286
+ range: {
36287
+ start: new Date(parsed.getTime() - 30 * 60 * 1e3),
36288
+ end: new Date(parsed.getTime() + 30 * 60 * 1e3)
36289
+ },
36290
+ matchedSubstring: tok
36291
+ };
36292
+ }
36293
+ }
36294
+ }
36295
+ return null;
36296
+ }
36297
+ function parseInstant(token, now) {
36298
+ const trimmed = token.trim().replace(/[,.!?;]+$/g, "");
36299
+ if (!trimmed) return null;
36300
+ const lower = trimmed.toLowerCase();
36301
+ if (lower === "now") return now;
36302
+ if (lower === "today") return startOfDay(now);
36303
+ if (lower === "yesterday") {
36304
+ return new Date(startOfDay(now).getTime() - MS_PER_DAY);
36305
+ }
36306
+ const isoLike = trimmed.replace(" ", "T");
36307
+ const parsed = new Date(isoLike);
36308
+ if (!Number.isNaN(parsed.getTime())) return parsed;
36309
+ return null;
36310
+ }
36311
+ function parseCount(token) {
36312
+ const lower = token.toLowerCase();
36313
+ if (/^\d+$/.test(lower)) {
36314
+ const n = Number.parseInt(lower, 10);
36315
+ return Number.isFinite(n) ? n : null;
36316
+ }
36317
+ return NUMBER_WORDS[lower] ?? null;
36318
+ }
36319
+ function startOfDay(d) {
36320
+ const out = new Date(d);
36321
+ out.setHours(0, 0, 0, 0);
36322
+ return out;
36323
+ }
36324
+ function startOfWeek(d) {
36325
+ const out = startOfDay(d);
36326
+ const dayOfWeek = out.getDay();
36327
+ const offsetToMonday = (dayOfWeek + 6) % 7;
36328
+ out.setDate(out.getDate() - offsetToMonday);
36329
+ return out;
36330
+ }
36331
+ function listFromRegistry(registry) {
36332
+ if (!registry) return [];
36333
+ if (Array.isArray(registry)) return registry;
36334
+ if (typeof registry.list === "function") {
36335
+ return registry.list();
36336
+ }
36337
+ return [];
36338
+ }
36339
+ function extractAgentNames(query, registry) {
36340
+ const records = listFromRegistry(registry);
36341
+ if (records.length === 0) return { matched: [], flagged: false };
36342
+ const lowerQuery = query.toLowerCase();
36343
+ const compactQuery = lowerQuery.replace(/[\s_-]+/g, "");
36344
+ const matched = [];
36345
+ const seen = /* @__PURE__ */ new Set();
36346
+ for (const rec of records) {
36347
+ const id = rec.agent_id;
36348
+ if (!id || seen.has(id)) continue;
36349
+ const idLower = id.toLowerCase();
36350
+ if (idLower.length < 3) continue;
36351
+ const idCompact = idLower.replace(/[\s_-]+/g, "");
36352
+ const wordRe = new RegExp(
36353
+ `\\b${idLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
36354
+ "i"
36355
+ );
36356
+ if (wordRe.test(query)) {
36357
+ matched.push(id);
36358
+ seen.add(id);
36359
+ continue;
36360
+ }
36361
+ if (idCompact.length >= 4 && compactQuery.includes(idCompact)) {
36362
+ matched.push(id);
36363
+ seen.add(id);
36364
+ }
36365
+ }
36366
+ const agentMention = lowerQuery.match(/\bagent\s+([a-z0-9_-]{3,40})/i);
36367
+ const flagged = matched.length === 0 && agentMention !== null && agentMention[1] !== void 0 && !records.some((r) => r.agent_id.toLowerCase() === agentMention[1]?.toLowerCase());
36368
+ return { matched, flagged };
36369
+ }
36370
+ function escapeRegex(s) {
36371
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36372
+ }
36373
+ function extractEventTypes(query, enumValues) {
36374
+ const lower = query.toLowerCase();
36375
+ const matched = [];
36376
+ const seen = /* @__PURE__ */ new Set();
36377
+ for (const ev of enumValues) {
36378
+ if (seen.has(ev)) continue;
36379
+ const re = new RegExp(`\\b${escapeRegex(ev)}\\b`, "i");
36380
+ if (re.test(query)) {
36381
+ matched.push(ev);
36382
+ seen.add(ev);
36383
+ }
36384
+ }
36385
+ for (const syn of EVENT_SYNONYMS) {
36386
+ const re = new RegExp(
36387
+ `\\b${syn.phrase.split(/\s+/).map(escapeRegex).join("\\s+")}\\b`,
36388
+ "i"
36389
+ );
36390
+ if (re.test(query)) {
36391
+ for (const c of syn.canonical) {
36392
+ if (seen.has(c)) continue;
36393
+ if (!enumValues.includes(c)) continue;
36394
+ matched.push(c);
36395
+ seen.add(c);
36396
+ }
36397
+ }
36398
+ }
36399
+ const globMatches = lower.match(/\b([a-z_]+)_\*/g) ?? [];
36400
+ for (const glob of globMatches) {
36401
+ const prefix = glob.slice(0, -2);
36402
+ for (const ev of enumValues) {
36403
+ if (seen.has(ev)) continue;
36404
+ if (ev.startsWith(prefix)) {
36405
+ matched.push(ev);
36406
+ seen.add(ev);
36407
+ }
36408
+ }
36409
+ }
36410
+ const eventNounMention = /\b(?:event|events|class|classes)\b/i.test(query) && matched.length === 0;
36411
+ return { matched, flagged: eventNounMention };
36412
+ }
36413
+ function deriveIntentPhrase(query, stripTokens) {
36414
+ let out = query;
36415
+ for (const tok of stripTokens) {
36416
+ if (!tok) continue;
36417
+ const re = new RegExp(escapeRegex(tok), "gi");
36418
+ out = out.replace(re, " ");
36419
+ }
36420
+ return out.replace(/\s+/g, " ").trim();
36421
+ }
36422
+ function computeConfidence(parsed) {
36423
+ const dims = [
36424
+ { present: parsed.hasTimeMention, resolved: parsed.timeResolved },
36425
+ { present: parsed.hasAgentMention, resolved: parsed.agentResolved },
36426
+ { present: parsed.hasEventMention, resolved: parsed.eventResolved }
36427
+ ];
36428
+ const present = dims.filter((d) => d.present);
36429
+ let base;
36430
+ if (present.length === 0) {
36431
+ base = parsed.intentEmpty ? 0 : 0.3;
36432
+ } else {
36433
+ const resolved = present.filter((d) => d.resolved).length;
36434
+ base = resolved / present.length;
36435
+ }
36436
+ const adjusted = base - 0.15 * parsed.ambiguityCount;
36437
+ if (adjusted < 0) return 0;
36438
+ if (adjusted > 1) return 1;
36439
+ return adjusted;
36440
+ }
36441
+ function parseQuery(query, opts) {
36442
+ const now = opts?.now ?? /* @__PURE__ */ new Date();
36443
+ const enumValues = opts?.eventClassEnum ?? CANONICAL_AUDIT_EVENT_CLASSES;
36444
+ const original = query ?? "";
36445
+ const trimmed = original.trim();
36446
+ if (trimmed.length === 0) {
36447
+ return {
36448
+ time_range: null,
36449
+ agent_names: [],
36450
+ event_types: [],
36451
+ intent_phrase: "",
36452
+ ambiguity_flags: ["no_signal_extracted"],
36453
+ parse_confidence: 0
36454
+ };
36455
+ }
36456
+ const ambiguity_flags = /* @__PURE__ */ new Set();
36457
+ const timeMatch = resolveTimeRange(trimmed, now);
36458
+ const hasTimeMention = TIME_MENTION_PROBE.test(trimmed);
36459
+ if (hasTimeMention && !timeMatch) {
36460
+ ambiguity_flags.add("unknown_time_token");
36461
+ }
36462
+ const agentResult = extractAgentNames(trimmed, opts?.registry);
36463
+ if (agentResult.flagged) {
36464
+ ambiguity_flags.add("unknown_agent_token");
36465
+ }
36466
+ const hasAgentMention = AGENT_MENTION_PROBE.test(trimmed);
36467
+ const eventResult = extractEventTypes(trimmed, enumValues);
36468
+ const hasEventMention = EVENT_MENTION_PROBE.test(trimmed);
36469
+ if (eventResult.flagged) {
36470
+ ambiguity_flags.add("unknown_event_token");
36471
+ }
36472
+ const stripTokens = [];
36473
+ if (timeMatch) stripTokens.push(timeMatch.matchedSubstring);
36474
+ for (const name of agentResult.matched) stripTokens.push(name);
36475
+ for (const ev of eventResult.matched) {
36476
+ if (trimmed.toLowerCase().includes(ev.toLowerCase())) {
36477
+ stripTokens.push(ev);
36478
+ }
36479
+ }
36480
+ const intent_phrase = deriveIntentPhrase(trimmed, stripTokens);
36481
+ const parse_confidence = computeConfidence({
36482
+ hasTimeMention,
36483
+ timeResolved: timeMatch !== null,
36484
+ hasAgentMention,
36485
+ agentResolved: agentResult.matched.length > 0,
36486
+ hasEventMention,
36487
+ eventResolved: eventResult.matched.length > 0,
36488
+ intentEmpty: intent_phrase.length === 0,
36489
+ ambiguityCount: ambiguity_flags.size
36490
+ });
36491
+ if (timeMatch === null && agentResult.matched.length === 0 && eventResult.matched.length === 0 && intent_phrase.length === 0) {
36492
+ ambiguity_flags.add("no_signal_extracted");
36493
+ }
36494
+ return {
36495
+ time_range: timeMatch ? timeMatch.range : null,
36496
+ agent_names: agentResult.matched,
36497
+ event_types: eventResult.matched,
36498
+ intent_phrase,
36499
+ ambiguity_flags: Array.from(ambiguity_flags),
36500
+ parse_confidence
36501
+ };
36502
+ }
36503
+ function isLowConfidence(parsed) {
36504
+ return parsed.parse_confidence < LLM_ASSIST_THRESHOLD;
36505
+ }
36506
+ async function parseQueryWithLlmAssist(query, llmAssist, opts) {
36507
+ const parsed = parseQuery(query, opts);
36508
+ if (!llmAssist || !isLowConfidence(parsed)) return parsed;
36509
+ let completion;
36510
+ try {
36511
+ completion = await llmAssist(query, parsed);
36512
+ } catch {
36513
+ return parsed;
36514
+ }
36515
+ if (!completion || typeof completion !== "object") return parsed;
36516
+ const merged = { ...parsed };
36517
+ if (parsed.time_range === null && completion.time_range) {
36518
+ merged.time_range = completion.time_range;
36519
+ }
36520
+ if (parsed.agent_names.length === 0 && Array.isArray(completion.agent_names)) {
36521
+ merged.agent_names = completion.agent_names.filter(
36522
+ (s) => typeof s === "string" && s.length > 0
36523
+ );
36524
+ }
36525
+ if (parsed.event_types.length === 0 && Array.isArray(completion.event_types)) {
36526
+ const allowed = new Set(opts?.eventClassEnum ?? CANONICAL_AUDIT_EVENT_CLASSES);
36527
+ merged.event_types = completion.event_types.filter(
36528
+ (s) => typeof s === "string" && allowed.has(s)
36529
+ );
36530
+ }
36531
+ merged.parse_confidence = Math.max(
36532
+ parsed.parse_confidence,
36533
+ computeConfidence({
36534
+ hasTimeMention: TIME_MENTION_PROBE.test(query),
36535
+ timeResolved: merged.time_range !== null,
36536
+ hasAgentMention: AGENT_MENTION_PROBE.test(query),
36537
+ agentResolved: merged.agent_names.length > 0,
36538
+ hasEventMention: EVENT_MENTION_PROBE.test(query),
36539
+ eventResolved: merged.event_types.length > 0,
36540
+ intentEmpty: merged.intent_phrase.length === 0,
36541
+ ambiguityCount: merged.ambiguity_flags.length
36542
+ })
36543
+ );
36544
+ return merged;
36545
+ }
36546
+ function auditSafeSummary(parsed) {
36547
+ return {
36548
+ time_range: parsed.time_range ? {
36549
+ start_iso: parsed.time_range.start.toISOString(),
36550
+ end_iso: parsed.time_range.end.toISOString(),
36551
+ ...parsed.time_range.relative_label !== void 0 ? { relative_label: parsed.time_range.relative_label } : {}
36552
+ } : null,
36553
+ agent_names: [...parsed.agent_names],
36554
+ event_types: [...parsed.event_types],
36555
+ ambiguity_flags: [...parsed.ambiguity_flags],
36556
+ parse_confidence: parsed.parse_confidence
36557
+ };
36558
+ }
36559
+ 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;
36560
+ var init_concierge_query_grammar = __esm({
36561
+ "src/chat/concierge-query-grammar.ts"() {
36562
+ init_constants4();
36563
+ init_operator_chat_audit_events();
36564
+ CANONICAL_AUDIT_EVENT_CLASSES = [
36565
+ // Lifecycle / policy
36566
+ "policy_change",
36567
+ "approval_request",
36568
+ "audit_truncate",
36569
+ "lockdown",
36570
+ "unwrap",
36571
+ // Exit bundle (Tier 1)
36572
+ "exit_bundle_export",
36573
+ "exit_bundle_import_activate",
36574
+ "exit_bundle_rekey",
36575
+ // Cross-harness approval aggregator
36576
+ "cross_harness_approval_aggregated",
36577
+ "cross_harness_approval_resolved",
36578
+ "cross_harness_approval_deduped",
36579
+ "cross_harness_approval_payload_decrypted",
36580
+ "cross_harness_approval_audit_trail_viewed",
36581
+ "cross_harness_approval_replayed",
36582
+ // Composition (full set from constants.ts)
36583
+ ...COMPOSITION_EVENT_TYPES,
36584
+ // Operator chat / concierge (full set from OPERATOR_CHAT_OPS)
36585
+ OPERATOR_CHAT_OPS.CONCIERGE_CHAT,
36586
+ OPERATOR_CHAT_OPS.AGENT_INSPECT_PANEL_OPENED,
36587
+ OPERATOR_CHAT_OPS.CONCIERGE_HISTORY_READ,
36588
+ OPERATOR_CHAT_OPS.CONCIERGE_THREAD_DELETED,
36589
+ OPERATOR_CHAT_OPS.CONCIERGE_MEMORY_READ_FAILED,
36590
+ OPERATOR_CHAT_OPS.CONCIERGE_CONTEXT_FETCHER_FAILED,
36591
+ // Bridge / commitment
36592
+ "bridge_commit",
36593
+ "bridge_verify",
36594
+ "bridge_attest",
36595
+ "proof_commitment",
36596
+ "proof_reveal",
36597
+ // Reputation
36598
+ "reputation_export",
36599
+ "reputation_import",
36600
+ "reputation_publish",
36601
+ "reputation_record",
36602
+ "reputation_query"
36603
+ ];
36604
+ EVENT_SYNONYMS = [
36605
+ { phrase: "approvals", canonical: ["approval_request", "cross_harness_approval_aggregated", "cross_harness_approval_resolved"] },
36606
+ { phrase: "approval", canonical: ["approval_request"] },
36607
+ { phrase: "policy changes", canonical: ["policy_change"] },
36608
+ { phrase: "policy change", canonical: ["policy_change"] },
36609
+ { phrase: "policy edits", canonical: ["policy_change"] },
36610
+ { phrase: "lockdowns", canonical: ["lockdown"] },
36611
+ { phrase: "exit bundles", canonical: ["exit_bundle_export", "exit_bundle_import_activate"] },
36612
+ { phrase: "exit bundle", canonical: ["exit_bundle_export"] },
36613
+ { phrase: "audit truncations", canonical: ["audit_truncate"] },
36614
+ { phrase: "audit truncation", canonical: ["audit_truncate"] },
36615
+ { phrase: "compositions", canonical: ["composition_receipt_packed", "composition_receipt_verified"] },
36616
+ { phrase: "receipts", canonical: ["composition_receipt_packed", "composition_receipt_verified"] },
36617
+ { phrase: "receipt verifications", canonical: ["composition_receipt_verified"] },
36618
+ { phrase: "concierge chats", canonical: [OPERATOR_CHAT_OPS.CONCIERGE_CHAT] },
36619
+ { phrase: "cross harness approvals", canonical: ["cross_harness_approval_aggregated", "cross_harness_approval_resolved"] }
36620
+ ];
36621
+ MS_PER_HOUR = 60 * 60 * 1e3;
36622
+ MS_PER_DAY = 24 * MS_PER_HOUR;
36623
+ NUMBER_WORDS = {
36624
+ a: 1,
36625
+ an: 1,
36626
+ one: 1,
36627
+ two: 2,
36628
+ three: 3,
36629
+ four: 4,
36630
+ five: 5,
36631
+ six: 6,
36632
+ seven: 7,
36633
+ eight: 8,
36634
+ nine: 9,
36635
+ ten: 10,
36636
+ twelve: 12,
36637
+ twentyfour: 24
36638
+ };
36639
+ 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;
36640
+ AGENT_MENTION_PROBE = /\bagent[s]?\b/i;
36641
+ EVENT_MENTION_PROBE = /\b(event|events|class|classes|approvals?|policy)\b/i;
36642
+ LLM_ASSIST_THRESHOLD = 0.5;
36643
+ }
36644
+ });
36645
+
36646
+ // src/chat/agent-context-cache.ts
34047
36647
  function approxTokenLen2(text) {
34048
36648
  return Math.ceil(text.length / 4);
34049
36649
  }
36650
+ function formatSnapshotLine(snapshot) {
36651
+ const flagLabel = snapshot.state_flags.join("+") || "no_flags";
36652
+ const work = snapshot.current_work_summary ? `, last: ${snapshot.current_work_summary}` : "";
36653
+ const verascore = snapshot.recent_verascore_delta_24h !== null ? `, verascore \u0394${snapshot.recent_verascore_delta_24h.toFixed(2)}` : "";
36654
+ return `- ${snapshot.agent_name} (template: ${snapshot.template}): ${flagLabel}, ${snapshot.recent_audit_count_24h} audit/24h, ${snapshot.recent_concordia_receipts_count_24h} receipts${verascore}${work}`;
36655
+ }
36656
+ function urgencyRank(snapshot) {
36657
+ for (let i = 0; i < STATE_FLAG_ORDER.length; i++) {
36658
+ if (snapshot.state_flags.includes(STATE_FLAG_ORDER[i])) {
36659
+ return i;
36660
+ }
36661
+ }
36662
+ return STATE_FLAG_ORDER.length;
36663
+ }
36664
+ function formatCurrentAgentStateSection(snapshots, opts) {
36665
+ if (snapshots.length === 0) return "";
36666
+ const budget = opts?.maxTokens ?? DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
36667
+ const sorted = [...snapshots].sort(
36668
+ (a, b) => urgencyRank(a) - urgencyRank(b)
36669
+ );
36670
+ const headerTokens = approxTokenLen2(`${SECTION_HEADER}
36671
+ `);
36672
+ const sepTokens = approxTokenLen2("\n");
36673
+ let runningTokens = headerTokens;
36674
+ const kept = [];
36675
+ for (const snap of sorted) {
36676
+ const line = formatSnapshotLine(snap);
36677
+ const tokens = approxTokenLen2(line) + (kept.length > 0 ? sepTokens : 0);
36678
+ if (kept.length === 0) {
36679
+ kept.push(line);
36680
+ runningTokens += tokens;
36681
+ continue;
36682
+ }
36683
+ if (runningTokens + tokens > budget) break;
36684
+ kept.push(line);
36685
+ runningTokens += tokens;
36686
+ }
36687
+ return `${SECTION_HEADER}
36688
+ ${kept.join("\n")}`;
36689
+ }
36690
+ function generateProactiveStarter(snapshots) {
36691
+ if (snapshots.length === 0) return null;
36692
+ const stuck = snapshots.filter((s) => s.state_flags.includes("stuck"));
36693
+ if (stuck.length > 0) {
36694
+ const first = stuck[0];
36695
+ if (first === void 0) return null;
36696
+ const last = first.current_work_summary ? ` (last: ${first.current_work_summary})` : "";
36697
+ return {
36698
+ text: `Your ${first.agent_name} agent looks stuck${last}. Should I check its session state?`,
36699
+ trigger: "stuck_agent",
36700
+ triggered_agents_count: stuck.length
36701
+ };
36702
+ }
36703
+ const pending = snapshots.filter(
36704
+ (s) => s.state_flags.includes("has_pending_approvals")
36705
+ );
36706
+ if (pending.length > 0) {
36707
+ const names = pending.slice(0, 3).map((s) => s.agent_name).join(", ");
36708
+ return {
36709
+ text: `You have pending approvals across ${names}. Want to walk through them?`,
36710
+ trigger: "pending_approvals",
36711
+ triggered_agents_count: pending.length
36712
+ };
36713
+ }
36714
+ const findings = snapshots.filter(
36715
+ (s) => s.state_flags.includes("has_open_findings")
36716
+ );
36717
+ if (findings.length > 0) {
36718
+ return {
36719
+ text: `Sentinel has open findings on ${findings.length} ${findings.length === 1 ? "agent" : "agents"}. Want a summary?`,
36720
+ trigger: "open_findings",
36721
+ triggered_agents_count: findings.length
36722
+ };
36723
+ }
36724
+ return {
36725
+ text: "Your fortress is quiet. Anything you'd like to inspect?",
36726
+ trigger: "all_idle",
36727
+ triggered_agents_count: snapshots.length
36728
+ };
36729
+ }
36730
+ var STATE_FLAG_ORDER, SECTION_HEADER, DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
36731
+ var init_agent_context_cache = __esm({
36732
+ "src/chat/agent-context-cache.ts"() {
36733
+ STATE_FLAG_ORDER = [
36734
+ "stuck",
36735
+ "has_pending_approvals",
36736
+ "has_open_findings",
36737
+ "active",
36738
+ "idle"
36739
+ ];
36740
+ SECTION_HEADER = "## Current agent state";
36741
+ DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET = 400;
36742
+ }
36743
+ });
36744
+ function approxTokenLen3(text) {
36745
+ return Math.ceil(text.length / 4);
36746
+ }
34050
36747
  function makeEventId(prefix) {
34051
36748
  return `${prefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
34052
36749
  }
@@ -34068,7 +36765,7 @@ function formatPriorTurnLine(turn) {
34068
36765
  function hashOf(input) {
34069
36766
  return hashToString(sha256.sha256(stringToBytes(input)));
34070
36767
  }
34071
- 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;
36768
+ 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;
34072
36769
  var init_operator_chat_service = __esm({
34073
36770
  "src/chat/operator-chat-service.ts"() {
34074
36771
  init_hashing();
@@ -34076,12 +36773,15 @@ var init_operator_chat_service = __esm({
34076
36773
  init_operator_chat_audit_events();
34077
36774
  init_operator_chat_types();
34078
36775
  init_concierge_context_router();
36776
+ init_concierge_query_grammar();
36777
+ init_agent_context_cache();
34079
36778
  DEFAULT_CONCIERGE_MAX_TOKENS = 512;
34080
36779
  DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
34081
36780
  DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
34082
36781
  DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
34083
36782
  DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
34084
36783
  DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET = 2e3;
36784
+ DEFAULT_CONCIERGE_AGENT_STATE_BUDGET = 400;
34085
36785
  SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
34086
36786
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
34087
36787
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -34126,6 +36826,17 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34126
36826
  contextFetchers;
34127
36827
  contextLlmAssist;
34128
36828
  dynamicContextBudget;
36829
+ agentRegistry;
36830
+ grammarLlmAssist;
36831
+ agentContextCache;
36832
+ agentStateBudget;
36833
+ /**
36834
+ * Per-thread guard so the proactive starter fires at most once per
36835
+ * fresh thread. Tracks the thread_id the starter was last offered
36836
+ * for; subsequent `getProactiveStarter()` calls within the same
36837
+ * thread return null instead of re-emitting.
36838
+ */
36839
+ starterOfferedForThreadId;
34129
36840
  /**
34130
36841
  * In-memory thread_id assigned to the active concierge session.
34131
36842
  * The first sendConcierge call after construction allocates a fresh
@@ -34164,6 +36875,16 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34164
36875
  this.contextLlmAssist = deps.conciergeContextLlmAssist;
34165
36876
  }
34166
36877
  this.dynamicContextBudget = deps.conciergeDynamicContextBudget !== void 0 && deps.conciergeDynamicContextBudget > 0 ? deps.conciergeDynamicContextBudget : DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET;
36878
+ if (deps.conciergeAgentRegistry) {
36879
+ this.agentRegistry = deps.conciergeAgentRegistry;
36880
+ }
36881
+ if (deps.conciergeGrammarLlmAssist) {
36882
+ this.grammarLlmAssist = deps.conciergeGrammarLlmAssist;
36883
+ }
36884
+ if (deps.conciergeAgentContextCache) {
36885
+ this.agentContextCache = deps.conciergeAgentContextCache;
36886
+ }
36887
+ this.agentStateBudget = deps.conciergeAgentStateBudget !== void 0 && deps.conciergeAgentStateBudget > 0 ? deps.conciergeAgentStateBudget : DEFAULT_CONCIERGE_AGENT_STATE_BUDGET;
34167
36888
  }
34168
36889
  // ── Concierge ─────────────────────────────────────────────────────────
34169
36890
  /**
@@ -34185,6 +36906,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34185
36906
  const nowMs = this.clock();
34186
36907
  if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
34187
36908
  this.activeMemoryThreadId = void 0;
36909
+ this.starterOfferedForThreadId = void 0;
34188
36910
  }
34189
36911
  const operatorMessage = {
34190
36912
  message_id: crypto.randomUUID(),
@@ -34222,6 +36944,12 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34222
36944
  await this.memory.appendTurn(threadId, "user", trimmed).catch(() => {
34223
36945
  });
34224
36946
  }
36947
+ const parsedGrammar = await this.runGrammarParse(filterResult.filtered);
36948
+ const agentSnapshots = this.agentContextCache ? this.agentContextCache.read() : [];
36949
+ const agentStateSection = this.agentContextCache ? formatCurrentAgentStateSection(agentSnapshots, {
36950
+ maxTokens: this.agentStateBudget
36951
+ }) : "";
36952
+ const renderedAgentCount = agentStateSection ? agentSnapshots.length : 0;
34225
36953
  const start = Date.now();
34226
36954
  let conciergeBody;
34227
36955
  let servedBy = "disabled";
@@ -34240,12 +36968,14 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34240
36968
  outcome = "substrate_disabled";
34241
36969
  } else {
34242
36970
  const dynamicResult = await this.runDynamicContextFold(
34243
- filterResult.filtered
36971
+ filterResult.filtered,
36972
+ parsedGrammar
34244
36973
  );
34245
36974
  dynamicCategoriesIncluded = dynamicResult.categoriesIncluded;
34246
36975
  const context = await this.assembleConciergeContext(
34247
36976
  priorTurns,
34248
- dynamicResult.section
36977
+ dynamicResult.section,
36978
+ agentStateSection
34249
36979
  );
34250
36980
  const response = await this.substrateSelector.invokeSummarize(
34251
36981
  "concierge",
@@ -34311,7 +37041,9 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34311
37041
  ...this.memory ? {
34312
37042
  prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
34313
37043
  } : {},
34314
- ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {}
37044
+ ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {},
37045
+ parsed_grammar: auditSafeSummary(parsedGrammar),
37046
+ ...this.agentContextCache !== void 0 ? { agent_context_snapshot_count: renderedAgentCount } : {}
34315
37047
  };
34316
37048
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
34317
37049
  return {
@@ -34422,6 +37154,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34422
37154
  if (!removed) return false;
34423
37155
  if (this.activeMemoryThreadId === threadId) {
34424
37156
  this.activeMemoryThreadId = void 0;
37157
+ this.starterOfferedForThreadId = void 0;
34425
37158
  }
34426
37159
  const payload = {
34427
37160
  version: "1.2",
@@ -34440,9 +37173,65 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34440
37173
  * Reset the active session memory thread. Subsequent sendConcierge
34441
37174
  * calls allocate a fresh thread_id. Surfaced for tests + future "new
34442
37175
  * conversation" affordance; not currently called by the dashboard.
37176
+ *
37177
+ * Tau-5: also clears the proactive-starter guard so the next
37178
+ * `getProactiveStarter()` call against the freshly-allocated thread
37179
+ * is eligible to fire.
34443
37180
  */
34444
37181
  resetConciergeMemoryThread() {
34445
37182
  this.activeMemoryThreadId = void 0;
37183
+ this.starterOfferedForThreadId = void 0;
37184
+ }
37185
+ /**
37186
+ * WP-V1.3-9 Tau-5: surface a proactive starter for the current
37187
+ * concierge session. Intended to be called by the dashboard UI when
37188
+ * the operator opens the chat surface, before any operator typing.
37189
+ *
37190
+ * Returns null when:
37191
+ * - No agent-context cache is wired (Tau-5 disabled).
37192
+ * - No concierge memory store is wired (no thread_id namespace).
37193
+ * - The cache snapshot has no signal (empty fortress).
37194
+ * - A starter has already been offered for the active thread (the
37195
+ * guard ensures one starter per fresh thread).
37196
+ *
37197
+ * Side effects:
37198
+ * - Allocates a fresh thread_id if none is active.
37199
+ * - Emits the `operator_concierge_proactive_suggestion_offered`
37200
+ * audit event with the trigger class + triggered_agents_count.
37201
+ * - Records the offered thread_id so the next call within the same
37202
+ * thread is a no-op.
37203
+ *
37204
+ * The returned starter's `text` is operator-visible copy; the
37205
+ * dashboard renders it as a system-message-style starter the
37206
+ * operator can accept (clicks/types follow-up) or dismiss (types a
37207
+ * new query).
37208
+ */
37209
+ getProactiveStarter() {
37210
+ if (!this.agentContextCache) return null;
37211
+ if (!this.memory) return null;
37212
+ const threadId = this.ensureActiveMemoryThread();
37213
+ if (this.starterOfferedForThreadId === threadId) return null;
37214
+ const snapshots = this.agentContextCache.read();
37215
+ const starter = generateProactiveStarter(snapshots);
37216
+ if (!starter) return null;
37217
+ const payload = {
37218
+ version: "1.2",
37219
+ event_id: makeEventId("conc-starter"),
37220
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
37221
+ identity_id: this.identityId,
37222
+ kind: "operator_concierge_proactive_suggestion_offered",
37223
+ surface: "concierge",
37224
+ thread_id: threadId,
37225
+ trigger: starter.trigger,
37226
+ triggered_agents_count: starter.triggered_agents_count
37227
+ };
37228
+ this.emit(
37229
+ OPERATOR_CHAT_OPS.CONCIERGE_PROACTIVE_SUGGESTION_OFFERED,
37230
+ payload,
37231
+ "success"
37232
+ );
37233
+ this.starterOfferedForThreadId = threadId;
37234
+ return starter;
34446
37235
  }
34447
37236
  ensureActiveMemoryThread() {
34448
37237
  if (!this.activeMemoryThreadId) {
@@ -34487,7 +37276,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34487
37276
  * if available; the v1.2 selector does not expose one, so structured
34488
37277
  * serialization is the canonical path for v1.3.
34489
37278
  */
34490
- async assembleConciergeContext(priorTurns = [], dynamicSection = "") {
37279
+ async assembleConciergeContext(priorTurns = [], dynamicSection = "", agentStateSection = "") {
34491
37280
  const ref = `## Sanctuary reference
34492
37281
  ${SANCTUARY_DOMAIN_REFERENCE}`;
34493
37282
  const priorSection = this.formatPriorTurnsSection(priorTurns);
@@ -34495,6 +37284,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
34495
37284
  return [
34496
37285
  ref,
34497
37286
  ...dynamicSection ? [dynamicSection] : [],
37287
+ ...agentStateSection ? [agentStateSection] : [],
34498
37288
  ...priorSection ? [priorSection] : [],
34499
37289
  "## Recent activity\n(no providers wired)",
34500
37290
  "## Wrapped agents\n(no providers wired)",
@@ -34509,6 +37299,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
34509
37299
  return [
34510
37300
  ref,
34511
37301
  ...dynamicSection ? [dynamicSection] : [],
37302
+ ...agentStateSection ? [agentStateSection] : [],
34512
37303
  ...priorSection ? [priorSection] : [],
34513
37304
  `## Recent activity
34514
37305
  ${activity}`,
@@ -34526,8 +37317,12 @@ ${inbox}`
34526
37317
  * proceeds with no fold. Returns the rendered section + the list of
34527
37318
  * categories whose data made it into the section (used for the
34528
37319
  * round-trip audit emission).
37320
+ *
37321
+ * Tau-4: receives the pre-parsed `ParsedQuery` and forwards it as the
37322
+ * `parsed` opt to `foldContext`, so fetchers see the structured
37323
+ * `FetcherHints` derived from it.
34529
37324
  */
34530
- async runDynamicContextFold(query) {
37325
+ async runDynamicContextFold(query, parsedGrammar) {
34531
37326
  if (!this.contextFetchers) {
34532
37327
  return { section: "", categoriesIncluded: [] };
34533
37328
  }
@@ -34536,10 +37331,24 @@ ${inbox}`
34536
37331
  ...this.contextLlmAssist ? { llmAssistClassify: this.contextLlmAssist } : {},
34537
37332
  onFetcherFailure: (category, error) => {
34538
37333
  this.emitContextFetcherFailed(category, classifyFetcherError(error));
34539
- }
37334
+ },
37335
+ parsed: parsedGrammar
34540
37336
  });
34541
37337
  return result;
34542
37338
  }
37339
+ /**
37340
+ * WP-V1.3-9 Tau-4: parse the (PII-filtered) operator query into a
37341
+ * `ParsedQuery`. Routes through the LLM-assist completion hook when
37342
+ * configured and the rule-based parse is below
37343
+ * `LLM_ASSIST_THRESHOLD`. Always returns a parse object (never
37344
+ * throws) so the audit emission can carry the result unconditionally.
37345
+ */
37346
+ async runGrammarParse(query) {
37347
+ return parseQueryWithLlmAssist(query, this.grammarLlmAssist, {
37348
+ ...this.agentRegistry !== void 0 ? { registry: this.agentRegistry } : {},
37349
+ eventClassEnum: CANONICAL_AUDIT_EVENT_CLASSES
37350
+ });
37351
+ }
34543
37352
  /**
34544
37353
  * Emit the WP-V1.3-9 Tau-3 fetcher-failure audit event. Pulled out
34545
37354
  * of the fold path so the dynamic-context handler stays readable.
@@ -34573,14 +37382,14 @@ ${inbox}`
34573
37382
  if (turns.length === 0) return "";
34574
37383
  const HEADER = "## Prior conversation";
34575
37384
  const lines = turns.map(formatPriorTurnLine);
34576
- const headerTokens = approxTokenLen2(`${HEADER}
37385
+ const headerTokens = approxTokenLen3(`${HEADER}
34577
37386
  `);
34578
- const sepTokens = approxTokenLen2("\n");
37387
+ const sepTokens = approxTokenLen3("\n");
34579
37388
  let runningTokens = headerTokens;
34580
37389
  let runningLines = [];
34581
37390
  for (let i = lines.length - 1; i >= 0; i--) {
34582
37391
  const line = lines[i];
34583
- const tokens = approxTokenLen2(line) + (runningLines.length > 0 ? sepTokens : 0);
37392
+ const tokens = approxTokenLen3(line) + (runningLines.length > 0 ? sepTokens : 0);
34584
37393
  if (runningTokens + tokens > this.historyTokenBudget) break;
34585
37394
  runningTokens += tokens;
34586
37395
  runningLines.push(line);
@@ -34608,7 +37417,7 @@ ${runningLines.join("\n")}`;
34608
37417
  function chatStorageKey(surface, threadKey) {
34609
37418
  return `${surface}.${threadKey}`;
34610
37419
  }
34611
- var OPERATOR_CHAT_NAMESPACE, HKDF_INFO2, OperatorChatStore;
37420
+ var OPERATOR_CHAT_NAMESPACE, HKDF_INFO3, OperatorChatStore;
34612
37421
  var init_operator_chat_store = __esm({
34613
37422
  "src/chat/operator-chat-store.ts"() {
34614
37423
  init_encryption();
@@ -34616,13 +37425,13 @@ var init_operator_chat_store = __esm({
34616
37425
  init_encoding();
34617
37426
  init_operator_chat_types();
34618
37427
  OPERATOR_CHAT_NAMESPACE = "_chat";
34619
- HKDF_INFO2 = "operator-chat-store-v1";
37428
+ HKDF_INFO3 = "operator-chat-store-v1";
34620
37429
  OperatorChatStore = class {
34621
37430
  storage;
34622
37431
  encryptionKey;
34623
37432
  constructor(storage, masterKey) {
34624
37433
  this.storage = storage;
34625
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
37434
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
34626
37435
  }
34627
37436
  /**
34628
37437
  * Load a thread. Returns null if no record exists or if the on-disk
@@ -34707,7 +37516,7 @@ var init_operator_chat_store = __esm({
34707
37516
  function bundleKey(threadId) {
34708
37517
  return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
34709
37518
  }
34710
- function stripKeyPrefix2(key) {
37519
+ function stripKeyPrefix3(key) {
34711
37520
  if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
34712
37521
  return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
34713
37522
  }
@@ -34718,7 +37527,7 @@ function lastTurnId(bundle) {
34718
37527
  }
34719
37528
  return max;
34720
37529
  }
34721
- var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO3, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
37530
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO4, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
34722
37531
  var init_concierge_memory_store = __esm({
34723
37532
  "src/chat/concierge-memory-store.ts"() {
34724
37533
  init_encryption();
@@ -34726,7 +37535,7 @@ var init_concierge_memory_store = __esm({
34726
37535
  init_encoding();
34727
37536
  CONCIERGE_MEMORY_NAMESPACE = "_chat";
34728
37537
  CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
34729
- HKDF_INFO3 = "concierge-memory-store-v1";
37538
+ HKDF_INFO4 = "concierge-memory-store-v1";
34730
37539
  DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
34731
37540
  MAX_BUNDLE_BYTES3 = 4 * 1024 * 1024;
34732
37541
  ConciergeMemoryStore = class {
@@ -34737,7 +37546,7 @@ var init_concierge_memory_store = __esm({
34737
37546
  locks;
34738
37547
  constructor(opts) {
34739
37548
  this.storage = opts.storage;
34740
- this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO3);
37549
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO4);
34741
37550
  this.fortressId = opts.fortressId;
34742
37551
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
34743
37552
  this.locks = /* @__PURE__ */ new Map();
@@ -34863,7 +37672,7 @@ var init_concierge_memory_store = __esm({
34863
37672
  );
34864
37673
  const summaries = [];
34865
37674
  for (const meta of entries) {
34866
- const threadId = stripKeyPrefix2(meta.key);
37675
+ const threadId = stripKeyPrefix3(meta.key);
34867
37676
  if (threadId === null) continue;
34868
37677
  const bundle = await this.loadBundle(threadId);
34869
37678
  if (!bundle || bundle.turns.length === 0) continue;
@@ -34916,7 +37725,7 @@ var init_concierge_memory_store = __esm({
34916
37725
  );
34917
37726
  let pruned = 0;
34918
37727
  for (const meta of entries) {
34919
- const threadId = stripKeyPrefix2(meta.key);
37728
+ const threadId = stripKeyPrefix3(meta.key);
34920
37729
  if (threadId === null) continue;
34921
37730
  pruned += await this.withLock(threadId, async () => {
34922
37731
  const bundle = await this.loadBundle(threadId);
@@ -35386,7 +38195,7 @@ var init_defaults = __esm({
35386
38195
  });
35387
38196
 
35388
38197
  // src/intelligence/policy-store.ts
35389
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO4, IntelligenceConfigStore;
38198
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO5, IntelligenceConfigStore;
35390
38199
  var init_policy_store = __esm({
35391
38200
  "src/intelligence/policy-store.ts"() {
35392
38201
  init_encryption();
@@ -35395,13 +38204,13 @@ var init_policy_store = __esm({
35395
38204
  init_defaults();
35396
38205
  INTELLIGENCE_NAMESPACE = "_intelligence";
35397
38206
  SUBSTRATE_CONFIG_KEY = "substrate-config";
35398
- HKDF_INFO4 = "intelligence-substrate-config";
38207
+ HKDF_INFO5 = "intelligence-substrate-config";
35399
38208
  IntelligenceConfigStore = class {
35400
38209
  storage;
35401
38210
  encryptionKey;
35402
38211
  constructor(storage, masterKey) {
35403
38212
  this.storage = storage;
35404
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO4);
38213
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO5);
35405
38214
  }
35406
38215
  /**
35407
38216
  * Load the operator's substrate config from disk. Returns the config
@@ -37051,7 +39860,7 @@ var init_memory = __esm({
37051
39860
 
37052
39861
  // src/contracts/v1.1/constants.ts
37053
39862
  var SIGNATURE_SCHEME_V12, EXIT_BUNDLE_MANIFEST_VERSION, EXIT_BUNDLE_ARTIFACT_KINDS;
37054
- var init_constants4 = __esm({
39863
+ var init_constants5 = __esm({
37055
39864
  "src/contracts/v1.1/constants.ts"() {
37056
39865
  SIGNATURE_SCHEME_V12 = "ed25519-v1";
37057
39866
  EXIT_BUNDLE_MANIFEST_VERSION = "SANCTUARY_EXIT_BUNDLE_V1";
@@ -37469,7 +40278,7 @@ async function verifyExitBundle(bundleDir, options = {}) {
37469
40278
  var InvalidExitBundleError, PRIVATE_MATERIAL_KEYS;
37470
40279
  var init_verifier2 = __esm({
37471
40280
  "src/exit/verifier.ts"() {
37472
- init_constants4();
40281
+ init_constants5();
37473
40282
  init_exit_bundle_manifest();
37474
40283
  init_encoding();
37475
40284
  init_hashing();
@@ -38281,7 +41090,7 @@ var init_bundle = __esm({
38281
41090
  "src/exit/bundle.ts"() {
38282
41091
  init_state_store();
38283
41092
  init_config();
38284
- init_constants4();
41093
+ init_constants5();
38285
41094
  init_canonical_json();
38286
41095
  init_hashing();
38287
41096
  init_encoding();
@@ -39352,6 +42161,38 @@ ${err.message}
39352
42161
  if (dashboard) {
39353
42162
  dashboard.setApprovalAggregator(approvalAggregator);
39354
42163
  }
42164
+ const sentinelFindingStore = new SentinelFindingStore({
42165
+ storage,
42166
+ masterKey,
42167
+ fortressId: fortressIdForAggregator
42168
+ });
42169
+ const sentinelRegistry = new SentinelRegistry();
42170
+ for (const entry of PHI1_BASELINE_CATALOG) {
42171
+ sentinelRegistry.register(entry);
42172
+ }
42173
+ const sentinelDispatcher = new SentinelDispatcher({
42174
+ registry: sentinelRegistry,
42175
+ findingStore: sentinelFindingStore,
42176
+ auditLog,
42177
+ fortressId: fortressIdForAggregator,
42178
+ identityId: aggregatorIdentityId
42179
+ });
42180
+ try {
42181
+ const persistedSubscriptions = await loadSentinelSubscriptions(
42182
+ config.storage_path
42183
+ );
42184
+ for (const sentinelId of persistedSubscriptions) {
42185
+ try {
42186
+ await sentinelDispatcher.subscribeSentinel(sentinelId);
42187
+ } catch {
42188
+ }
42189
+ }
42190
+ } catch {
42191
+ }
42192
+ sentinelDispatcher.start();
42193
+ if (dashboard) {
42194
+ dashboard.setSentinelDispatcher(sentinelDispatcher);
42195
+ }
39355
42196
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
39356
42197
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
39357
42198
  config,
@@ -39545,6 +42386,11 @@ var init_src = __esm({
39545
42386
  init_approval_aggregator();
39546
42387
  init_aggregator_backed_channel();
39547
42388
  init_aggregator_store();
42389
+ init_sentinel_finding_store();
42390
+ init_sentinel_registry();
42391
+ init_sentinel_dispatcher();
42392
+ init_sentinels();
42393
+ init_subscription_store();
39548
42394
  init_tools4();
39549
42395
  init_router();
39550
42396
  init_router();
@@ -44714,6 +47560,232 @@ var init_intelligence = __esm({
44714
47560
  }
44715
47561
  });
44716
47562
 
47563
+ // src/cli/sentinel.ts
47564
+ var sentinel_exports = {};
47565
+ __export(sentinel_exports, {
47566
+ runSentinelCommand: () => runSentinelCommand
47567
+ });
47568
+ async function runSentinelCommand(args) {
47569
+ const out = args.out ?? process.stdout;
47570
+ const err = args.err ?? process.stderr;
47571
+ const [sub, ...rest] = args.argv;
47572
+ if (!sub || sub === "--help" || sub === "-h") {
47573
+ printUsage6(out);
47574
+ return 0;
47575
+ }
47576
+ try {
47577
+ switch (sub) {
47578
+ case "list":
47579
+ return cmdList4(out);
47580
+ case "list-subscribed":
47581
+ return await cmdListSubscribed(rest, { out, err, args });
47582
+ case "subscribe":
47583
+ return await cmdSubscribe(rest, { out, err, args });
47584
+ case "unsubscribe":
47585
+ return await cmdUnsubscribe(rest, { out, err, args });
47586
+ case "findings":
47587
+ return await cmdFindings(rest, { out, err, args });
47588
+ default:
47589
+ err.write(`Unknown subcommand: ${sub}
47590
+ `);
47591
+ printUsage6(err);
47592
+ return 2;
47593
+ }
47594
+ } catch (e) {
47595
+ const msg = e instanceof Error ? e.message : String(e);
47596
+ err.write(`sanctuary sentinel: ${msg}
47597
+ `);
47598
+ return 1;
47599
+ }
47600
+ }
47601
+ function printUsage6(s) {
47602
+ s.write(`Usage: sanctuary sentinel <command> [args]
47603
+
47604
+ list Show the Phi-1 catalog of available
47605
+ sentinels (egress-volume only at v1.3
47606
+ Phi-1; more land in Phi-2 ... Phi-5).
47607
+ list-subscribed Show which sentinels this fortress has
47608
+ opted into. Loads from
47609
+ <storage>/sentinel-subscriptions.json.
47610
+ subscribe <sentinel-id> Opt in. Writes the subscription file.
47611
+ The server picks it up on next boot.
47612
+ unsubscribe <sentinel-id> Opt out.
47613
+ findings [opts] Read recent findings. Decrypts the
47614
+ sentinel findings store (uses the
47615
+ same passphrase as the cocoon master
47616
+ key).
47617
+ --since <iso> Filter observed_at >= iso.
47618
+ --severity <info|warn|alert> Filter by severity.
47619
+ --sentinel-id <id> Filter by emitting sentinel.
47620
+ --agent-id <id> Filter by agent attribution.
47621
+ --limit <n> Cap result count (default 100).
47622
+ `);
47623
+ }
47624
+ function cmdList4(out) {
47625
+ for (const entry of PHI1_BASELINE_CATALOG) {
47626
+ out.write(`${entry.sentinelId}
47627
+ ${entry.description}
47628
+ `);
47629
+ }
47630
+ if (PHI1_BASELINE_CATALOG.length === 0) {
47631
+ out.write("(no sentinels registered)\n");
47632
+ }
47633
+ return 0;
47634
+ }
47635
+ async function cmdListSubscribed(argv, ctx) {
47636
+ const storagePath = await resolveStoragePath2(ctx.args);
47637
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47638
+ if (subscribed.size === 0) {
47639
+ ctx.out.write("(no subscriptions)\n");
47640
+ return 0;
47641
+ }
47642
+ for (const id of [...subscribed].sort()) {
47643
+ ctx.out.write(`${id}
47644
+ `);
47645
+ }
47646
+ return 0;
47647
+ }
47648
+ async function cmdSubscribe(argv, ctx) {
47649
+ const sentinelId = argv[0];
47650
+ if (!sentinelId) {
47651
+ ctx.err.write("subscribe requires a sentinel-id\n");
47652
+ return 2;
47653
+ }
47654
+ const known = PHI1_BASELINE_CATALOG.find(
47655
+ (entry) => entry.sentinelId === sentinelId
47656
+ );
47657
+ if (!known) {
47658
+ ctx.err.write(`Unknown sentinel: ${sentinelId}
47659
+ `);
47660
+ return 2;
47661
+ }
47662
+ const storagePath = await resolveStoragePath2(ctx.args);
47663
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47664
+ if (subscribed.has(sentinelId)) {
47665
+ ctx.out.write(`Already subscribed: ${sentinelId}
47666
+ `);
47667
+ return 0;
47668
+ }
47669
+ subscribed.add(sentinelId);
47670
+ await saveSentinelSubscriptions(storagePath, subscribed);
47671
+ ctx.out.write(
47672
+ `Subscribed: ${sentinelId}
47673
+ Restart Sanctuary or wait for the next dispatcher tick to begin evaluation.
47674
+ `
47675
+ );
47676
+ return 0;
47677
+ }
47678
+ async function cmdUnsubscribe(argv, ctx) {
47679
+ const sentinelId = argv[0];
47680
+ if (!sentinelId) {
47681
+ ctx.err.write("unsubscribe requires a sentinel-id\n");
47682
+ return 2;
47683
+ }
47684
+ const storagePath = await resolveStoragePath2(ctx.args);
47685
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47686
+ if (!subscribed.has(sentinelId)) {
47687
+ ctx.out.write(`Not subscribed: ${sentinelId}
47688
+ `);
47689
+ return 0;
47690
+ }
47691
+ subscribed.delete(sentinelId);
47692
+ await saveSentinelSubscriptions(storagePath, subscribed);
47693
+ ctx.out.write(`Unsubscribed: ${sentinelId}
47694
+ `);
47695
+ return 0;
47696
+ }
47697
+ async function cmdFindings(argv, ctx) {
47698
+ const filters = parseFindingFilters(argv);
47699
+ const storagePath = await resolveStoragePath2(ctx.args);
47700
+ const storage = new FilesystemStorage(`${storagePath}/state`);
47701
+ let passphrase = ctx.args.passphrase ?? process.env["SANCTUARY_PASSPHRASE"];
47702
+ if (!passphrase) {
47703
+ const resolved = await getOrCreatePassphrase();
47704
+ passphrase = resolved.value;
47705
+ }
47706
+ let existingParams;
47707
+ try {
47708
+ const raw = await storage.read("_meta", "key-params");
47709
+ if (raw) existingParams = JSON.parse(bytesToString(raw));
47710
+ } catch {
47711
+ }
47712
+ const { key: masterKey, params } = await deriveMasterKey(
47713
+ passphrase,
47714
+ existingParams
47715
+ );
47716
+ if (!existingParams) {
47717
+ await storage.write(
47718
+ "_meta",
47719
+ "key-params",
47720
+ stringToBytes(JSON.stringify(params))
47721
+ );
47722
+ }
47723
+ const fortressId = fortressIdFromStoragePath(storagePath);
47724
+ const store = new SentinelFindingStore({
47725
+ storage,
47726
+ masterKey,
47727
+ fortressId
47728
+ });
47729
+ const findings = await store.listFindings({
47730
+ limit: filters.limit ?? 100,
47731
+ ...filters.since !== void 0 ? { since: filters.since } : {},
47732
+ ...filters.severity !== void 0 ? { severity: filters.severity } : {},
47733
+ ...filters.sentinelId !== void 0 ? { sentinelId: filters.sentinelId } : {},
47734
+ ...filters.agentId !== void 0 ? { agentId: filters.agentId } : {}
47735
+ });
47736
+ if (findings.length === 0) {
47737
+ ctx.out.write("(no findings)\n");
47738
+ return 0;
47739
+ }
47740
+ for (const finding of findings) {
47741
+ ctx.out.write(
47742
+ `[${finding.observed_at}] ${finding.severity.toUpperCase()} ${finding.sentinel_id}${finding.agent_id ? ` (agent ${finding.agent_id})` : ""}: ${finding.summary}
47743
+ `
47744
+ );
47745
+ }
47746
+ return 0;
47747
+ }
47748
+ function parseFindingFilters(argv) {
47749
+ const filters = {};
47750
+ for (let i = 0; i < argv.length; i += 1) {
47751
+ const arg = argv[i];
47752
+ if (arg === "--since" && argv[i + 1]) {
47753
+ filters.since = argv[++i];
47754
+ } else if (arg === "--severity" && argv[i + 1]) {
47755
+ const next = argv[++i];
47756
+ if (next === "info" || next === "warn" || next === "alert") {
47757
+ filters.severity = next;
47758
+ }
47759
+ } else if (arg === "--sentinel-id" && argv[i + 1]) {
47760
+ filters.sentinelId = argv[++i];
47761
+ } else if (arg === "--agent-id" && argv[i + 1]) {
47762
+ filters.agentId = argv[++i];
47763
+ } else if (arg === "--limit" && argv[i + 1]) {
47764
+ const n = Number.parseInt(argv[++i], 10);
47765
+ if (!Number.isNaN(n) && n > 0) filters.limit = n;
47766
+ }
47767
+ }
47768
+ return filters;
47769
+ }
47770
+ async function resolveStoragePath2(args) {
47771
+ if (args.storagePath) return args.storagePath;
47772
+ const config = await loadConfig();
47773
+ return config.storage_path;
47774
+ }
47775
+ var init_sentinel2 = __esm({
47776
+ "src/cli/sentinel.ts"() {
47777
+ init_config();
47778
+ init_filesystem();
47779
+ init_key_derivation();
47780
+ init_encoding();
47781
+ init_passphrase();
47782
+ init_wiring();
47783
+ init_sentinel_finding_store();
47784
+ init_subscription_store();
47785
+ init_sentinels();
47786
+ }
47787
+ });
47788
+
44717
47789
  // src/mcp/broker-server.ts
44718
47790
  var broker_server_exports = {};
44719
47791
  __export(broker_server_exports, {
@@ -44809,7 +47881,7 @@ function createBrokerMcpServer(broker, opts) {
44809
47881
  case "broker/request_token": {
44810
47882
  const skill = requireString(args, "skill");
44811
47883
  const secret = requireString(args, "secret");
44812
- const scopeRaw = optionalString(args, "scope");
47884
+ const scopeRaw = optionalString2(args, "scope");
44813
47885
  const scope = scopeRaw === "rotate" ? "rotate" : scopeRaw === "read" ? "read" : void 0;
44814
47886
  const ttl = optionalNumber(args, "ttl_seconds");
44815
47887
  const binding = await broker.issueToken({
@@ -44841,7 +47913,7 @@ function createBrokerMcpServer(broker, opts) {
44841
47913
  return ok({ grants });
44842
47914
  }
44843
47915
  case "broker/audit_query": {
44844
- const since = optionalString(args, "since");
47916
+ const since = optionalString2(args, "since");
44845
47917
  const limit = optionalNumber(args, "limit");
44846
47918
  const summary = await broker.queryAudit({ since, limit });
44847
47919
  return ok(summary);
@@ -44866,7 +47938,7 @@ function requireString(args, key) {
44866
47938
  }
44867
47939
  return v;
44868
47940
  }
44869
- function optionalString(args, key) {
47941
+ function optionalString2(args, key) {
44870
47942
  const v = args[key];
44871
47943
  return typeof v === "string" && v.length > 0 ? v : void 0;
44872
47944
  }
@@ -45573,6 +48645,11 @@ async function main() {
45573
48645
  const code = await runIntelligenceCommand2({ argv: args.slice(1) });
45574
48646
  process.exit(code);
45575
48647
  }
48648
+ if (args[0] === "sentinel") {
48649
+ const { runSentinelCommand: runSentinelCommand2 } = await Promise.resolve().then(() => (init_sentinel2(), sentinel_exports));
48650
+ const code = await runSentinelCommand2({ argv: args.slice(1) });
48651
+ process.exit(code);
48652
+ }
45576
48653
  if (args[0] === "broker-server") {
45577
48654
  const { openBroker: openBroker2 } = await Promise.resolve().then(() => (init_open(), open_exports));
45578
48655
  const { createBrokerMcpServer: createBrokerMcpServer2 } = await Promise.resolve().then(() => (init_broker_server(), broker_server_exports));