@sanctuary-framework/mcp-server 1.2.7 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -16453,6 +16453,119 @@ async function handleApprovalInboxRoute(deps, req, res) {
16453
16453
  }
16454
16454
  }
16455
16455
 
16456
+ // src/sentinel/sentinel-routes.ts
16457
+ var SENTINEL_API_PREFIX = "/api/sentinels";
16458
+ var FINDINGS_DEFAULT_LIMIT = 100;
16459
+ var FINDINGS_MAX_LIMIT = 500;
16460
+ function writeJSON5(res, status, payload) {
16461
+ res.writeHead(status, {
16462
+ "Content-Type": "application/json",
16463
+ "Cache-Control": "no-store"
16464
+ });
16465
+ res.end(JSON.stringify(payload));
16466
+ }
16467
+ function isSeverity(value) {
16468
+ return value === "info" || value === "warn" || value === "alert";
16469
+ }
16470
+ function parseLimit3(raw, defaultValue, max) {
16471
+ if (raw === null || raw === "") return defaultValue;
16472
+ const parsed = Number.parseInt(raw, 10);
16473
+ if (Number.isNaN(parsed) || parsed < 0) return defaultValue;
16474
+ return Math.min(parsed, max);
16475
+ }
16476
+ function matchSubscribeRoute(path) {
16477
+ const prefix = `${SENTINEL_API_PREFIX}/`;
16478
+ if (!path.startsWith(prefix)) return null;
16479
+ const rest = path.slice(prefix.length);
16480
+ if (!rest.endsWith("/subscribe")) return null;
16481
+ const sentinelId = rest.slice(0, rest.length - "/subscribe".length);
16482
+ if (sentinelId.length === 0) return null;
16483
+ return { sentinelId: decodeURIComponent(sentinelId) };
16484
+ }
16485
+ async function handleSentinelRoute(deps, req, res) {
16486
+ const host = req.headers.host || "localhost";
16487
+ const url = new URL(req.url ?? "/", `http://${host}`);
16488
+ const method = (req.method ?? "GET").toUpperCase();
16489
+ const path = url.pathname;
16490
+ if (path !== SENTINEL_API_PREFIX && !path.startsWith(`${SENTINEL_API_PREFIX}/`)) {
16491
+ return false;
16492
+ }
16493
+ const checkAuth = authMiddleware(deps.authConfig);
16494
+ if (!checkAuth(req, res, url)) return true;
16495
+ const dispatcher = deps.dispatcher;
16496
+ const registry = dispatcher.getRegistry();
16497
+ const findingStore = dispatcher.getFindingStore();
16498
+ try {
16499
+ if (method === "GET" && path === SENTINEL_API_PREFIX) {
16500
+ const catalog = registry.listCatalog();
16501
+ writeJSON5(res, 200, { ok: true, data: { catalog } });
16502
+ return true;
16503
+ }
16504
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/subscribed`) {
16505
+ const subscribed = registry.listSubscribed();
16506
+ writeJSON5(res, 200, { ok: true, data: { subscribed } });
16507
+ return true;
16508
+ }
16509
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/findings`) {
16510
+ const limit = parseLimit3(
16511
+ url.searchParams.get("limit"),
16512
+ FINDINGS_DEFAULT_LIMIT,
16513
+ FINDINGS_MAX_LIMIT
16514
+ );
16515
+ const since = url.searchParams.get("since") ?? void 0;
16516
+ const severityRaw = url.searchParams.get("severity") ?? void 0;
16517
+ const sentinelIdFilter = url.searchParams.get("sentinel_id") ?? void 0;
16518
+ const agentIdFilter = url.searchParams.get("agent_id") ?? void 0;
16519
+ const severity = severityRaw && isSeverity(severityRaw) ? severityRaw : void 0;
16520
+ const findings = await findingStore.listFindings({
16521
+ limit,
16522
+ ...since !== void 0 ? { since } : {},
16523
+ ...severity !== void 0 ? { severity } : {},
16524
+ ...sentinelIdFilter !== void 0 ? { sentinelId: sentinelIdFilter } : {},
16525
+ ...agentIdFilter !== void 0 ? { agentId: agentIdFilter } : {}
16526
+ });
16527
+ writeJSON5(res, 200, { ok: true, data: { findings } });
16528
+ return true;
16529
+ }
16530
+ const subscribeMatch = matchSubscribeRoute(path);
16531
+ if (subscribeMatch) {
16532
+ if (method === "POST") {
16533
+ try {
16534
+ await dispatcher.subscribeSentinel(subscribeMatch.sentinelId);
16535
+ writeJSON5(res, 200, {
16536
+ ok: true,
16537
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: true }
16538
+ });
16539
+ } catch (err) {
16540
+ const msg = err instanceof Error ? err.message : String(err);
16541
+ if (msg.startsWith("sentinel-registry: unknown sentinel")) {
16542
+ writeJSON5(res, 404, { ok: false, error: "not_found" });
16543
+ } else {
16544
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
16545
+ }
16546
+ }
16547
+ return true;
16548
+ }
16549
+ if (method === "DELETE") {
16550
+ const removed = await dispatcher.unsubscribeSentinel(
16551
+ subscribeMatch.sentinelId
16552
+ );
16553
+ writeJSON5(res, 200, {
16554
+ ok: true,
16555
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: false, removed }
16556
+ });
16557
+ return true;
16558
+ }
16559
+ }
16560
+ writeJSON5(res, 404, { ok: false, error: "not_found", path });
16561
+ return true;
16562
+ } catch (err) {
16563
+ const msg = err instanceof Error ? err.message : String(err);
16564
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
16565
+ return true;
16566
+ }
16567
+ }
16568
+
16456
16569
  // src/principal-policy/dashboard.ts
16457
16570
  var SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
16458
16571
  var SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
@@ -16525,6 +16638,13 @@ var DashboardApprovalChannel = class {
16525
16638
  * the operator-facing query / decision surface.
16526
16639
  */
16527
16640
  approvalAggregator = null;
16641
+ /**
16642
+ * v1.3 WP-V1.3-1 Phi-1 Sentinel dispatcher. Mounted additively at
16643
+ * `/api/sentinels/*` when set. Sentinel surface is read-only against
16644
+ * the audit log; subscribe/unsubscribe writes flow through the
16645
+ * dispatcher's audited paths.
16646
+ */
16647
+ sentinelDispatcher = null;
16528
16648
  constructor(config) {
16529
16649
  this.config = config;
16530
16650
  this.authToken = config.auth_token;
@@ -16584,6 +16704,14 @@ var DashboardApprovalChannel = class {
16584
16704
  setApprovalAggregator(aggregator) {
16585
16705
  this.approvalAggregator = aggregator;
16586
16706
  }
16707
+ /**
16708
+ * v1.3 WP-V1.3-1 Phi-1: bind the Sentinel dispatcher. Once set,
16709
+ * requests to `/api/sentinels/*` route through `handleSentinelRoute`.
16710
+ * Pass `null` to detach (used by tests + during shutdown).
16711
+ */
16712
+ setSentinelDispatcher(dispatcher) {
16713
+ this.sentinelDispatcher = dispatcher;
16714
+ }
16587
16715
  /**
16588
16716
  * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
16589
16717
  * before the legacy approval route table. Returns true when served.
@@ -16603,6 +16731,25 @@ var DashboardApprovalChannel = class {
16603
16731
  res
16604
16732
  );
16605
16733
  }
16734
+ /**
16735
+ * v1.3 WP-V1.3-1 Phi-1 dispatch entry point. Routes `/api/sentinels/*`
16736
+ * requests through the sentinel router when a dispatcher has been
16737
+ * bound. Returns true when served.
16738
+ */
16739
+ async dispatchSentinel(req, res) {
16740
+ if (!this.sentinelDispatcher) return false;
16741
+ return handleSentinelRoute(
16742
+ {
16743
+ authConfig: {
16744
+ loopbackAutoAuth: this._autoAuthLocalhost,
16745
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
16746
+ },
16747
+ dispatcher: this.sentinelDispatcher
16748
+ },
16749
+ req,
16750
+ res
16751
+ );
16752
+ }
16606
16753
  /**
16607
16754
  * v1.1 dispatch entry point. Called from `handleRequest` before the
16608
16755
  * legacy route table. Returns true when the request was served by v1.1
@@ -16990,6 +17137,18 @@ var DashboardApprovalChannel = class {
16990
17137
  });
16991
17138
  return;
16992
17139
  }
17140
+ if (this.sentinelDispatcher && url.pathname.startsWith(SENTINEL_API_PREFIX)) {
17141
+ this.dispatchSentinel(req, res).then((handled) => {
17142
+ if (handled) return;
17143
+ this.handleLegacyRequest(req, res, url, method);
17144
+ }).catch(() => {
17145
+ if (!res.headersSent) {
17146
+ res.writeHead(500, { "Content-Type": "application/json" });
17147
+ res.end(JSON.stringify({ error: "Internal server error" }));
17148
+ }
17149
+ });
17150
+ return;
17151
+ }
16993
17152
  if (this.v11Bindings) {
16994
17153
  this.dispatchV11(req, res, url, method).then((handled) => {
16995
17154
  if (handled) return;
@@ -20211,122 +20370,1764 @@ var AggregatorPayloadStore = class {
20211
20370
  this.fortressId = opts.fortressId;
20212
20371
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_AGGREGATOR_PAYLOAD_RETENTION_DAYS;
20213
20372
  }
20214
- /**
20215
- * Persist `payload` under the given aggregator_id. Idempotent; calling
20216
- * twice with the same id rewrites the bundle (retention_until is
20217
- * recomputed). Returns the bundle's retention_until ISO-8601 timestamp
20218
- * so callers can log it.
20219
- */
20220
- async savePayload(aggregatorId, payload) {
20221
- const now = /* @__PURE__ */ new Date();
20222
- const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
20223
- const retentionUntil = new Date(now.getTime() + retentionMs);
20224
- const bundle = {
20225
- version: 1,
20226
- aggregator_id: aggregatorId,
20227
- fortress_id: this.fortressId,
20228
- created_at: now.toISOString(),
20229
- retention_until: retentionUntil.toISOString(),
20230
- payload
20373
+ /**
20374
+ * Persist `payload` under the given aggregator_id. Idempotent; calling
20375
+ * twice with the same id rewrites the bundle (retention_until is
20376
+ * recomputed). Returns the bundle's retention_until ISO-8601 timestamp
20377
+ * so callers can log it.
20378
+ */
20379
+ async savePayload(aggregatorId, payload) {
20380
+ const now = /* @__PURE__ */ new Date();
20381
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
20382
+ const retentionUntil = new Date(now.getTime() + retentionMs);
20383
+ const bundle = {
20384
+ version: 1,
20385
+ aggregator_id: aggregatorId,
20386
+ fortress_id: this.fortressId,
20387
+ created_at: now.toISOString(),
20388
+ retention_until: retentionUntil.toISOString(),
20389
+ payload
20390
+ };
20391
+ const aad = stringToBytes(aggregatorId);
20392
+ const plaintext = stringToBytes(JSON.stringify(bundle));
20393
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
20394
+ await this.storage.write(
20395
+ AGGREGATOR_PAYLOAD_NAMESPACE,
20396
+ payloadKey(aggregatorId),
20397
+ stringToBytes(JSON.stringify(envelope))
20398
+ );
20399
+ return bundle.retention_until;
20400
+ }
20401
+ /**
20402
+ * Read the persisted payload for the aggregator_id. Returns null if no
20403
+ * bundle exists, the bundle is corrupted, or AAD binding fails.
20404
+ */
20405
+ async loadPayload(aggregatorId) {
20406
+ const key = payloadKey(aggregatorId);
20407
+ let raw;
20408
+ try {
20409
+ raw = await this.storage.read(AGGREGATOR_PAYLOAD_NAMESPACE, key);
20410
+ } catch {
20411
+ return null;
20412
+ }
20413
+ if (!raw) return null;
20414
+ if (raw.length > MAX_BUNDLE_BYTES2) return null;
20415
+ try {
20416
+ const envelope = JSON.parse(bytesToString(raw));
20417
+ const aad = stringToBytes(aggregatorId);
20418
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
20419
+ const parsed = JSON.parse(
20420
+ bytesToString(plaintext)
20421
+ );
20422
+ if (parsed.version !== 1) return null;
20423
+ if (parsed.aggregator_id !== aggregatorId) return null;
20424
+ return parsed.payload;
20425
+ } catch {
20426
+ return null;
20427
+ }
20428
+ }
20429
+ /**
20430
+ * Delete the persisted payload. Returns true when a bundle was removed,
20431
+ * false when none existed.
20432
+ */
20433
+ async deletePayload(aggregatorId) {
20434
+ const key = payloadKey(aggregatorId);
20435
+ const existed = await this.storage.exists(
20436
+ AGGREGATOR_PAYLOAD_NAMESPACE,
20437
+ key
20438
+ );
20439
+ if (!existed) return false;
20440
+ try {
20441
+ await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, key);
20442
+ } catch {
20443
+ return false;
20444
+ }
20445
+ return true;
20446
+ }
20447
+ /**
20448
+ * Drop expired payload bundles. Returns the count of bundles pruned.
20449
+ * Caller wires this into the cocoon-unlock initialization path.
20450
+ */
20451
+ async pruneExpired(now) {
20452
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
20453
+ const entries = await this.storage.list(
20454
+ AGGREGATOR_PAYLOAD_NAMESPACE,
20455
+ AGGREGATOR_PAYLOAD_KEY_PREFIX
20456
+ );
20457
+ let pruned = 0;
20458
+ for (const meta of entries) {
20459
+ const aggregatorId = stripKeyPrefix(meta.key);
20460
+ if (aggregatorId === null) continue;
20461
+ const raw = await this.storage.read(
20462
+ AGGREGATOR_PAYLOAD_NAMESPACE,
20463
+ meta.key
20464
+ );
20465
+ if (!raw) continue;
20466
+ try {
20467
+ const envelope = JSON.parse(bytesToString(raw));
20468
+ const aad = stringToBytes(aggregatorId);
20469
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
20470
+ const parsed = JSON.parse(
20471
+ bytesToString(plaintext)
20472
+ );
20473
+ if (parsed.retention_until <= cutoff) {
20474
+ await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, meta.key);
20475
+ pruned += 1;
20476
+ }
20477
+ } catch {
20478
+ }
20479
+ }
20480
+ return { pruned };
20481
+ }
20482
+ };
20483
+ function payloadKey(aggregatorId) {
20484
+ return `${AGGREGATOR_PAYLOAD_KEY_PREFIX}${aggregatorId}`;
20485
+ }
20486
+ function stripKeyPrefix(key) {
20487
+ if (!key.startsWith(AGGREGATOR_PAYLOAD_KEY_PREFIX)) return null;
20488
+ return key.slice(AGGREGATOR_PAYLOAD_KEY_PREFIX.length);
20489
+ }
20490
+
20491
+ // src/sentinel/sentinel-finding-store.ts
20492
+ init_encryption();
20493
+ init_encoding();
20494
+
20495
+ // src/sentinel/types.ts
20496
+ var SENTINEL_SUMMARY_MAX_CHARS = 240;
20497
+ var SENTINEL_AUDIT_OPS = {
20498
+ SUBSCRIBED: "sentinel_subscribed",
20499
+ UNSUBSCRIBED: "sentinel_unsubscribed",
20500
+ FINDING_EMITTED: "sentinel_finding_emitted",
20501
+ EVALUATION_FAILED: "sentinel_evaluation_failed"
20502
+ };
20503
+ var SENTINEL_OBSERVED_AUDIT_OPS = {
20504
+ /** Proxy router emits this on every outbound call (success or failure). */
20505
+ PROXY_CALL_PREFIX: "proxy_call:"
20506
+ };
20507
+ function isProxyCallAuditEntry(entry) {
20508
+ return entry.operation.startsWith(
20509
+ SENTINEL_OBSERVED_AUDIT_OPS.PROXY_CALL_PREFIX
20510
+ );
20511
+ }
20512
+ function proxyServerFromAuditEntry(entry) {
20513
+ if (!isProxyCallAuditEntry(entry)) return null;
20514
+ const details = entry.details;
20515
+ if (!details) return null;
20516
+ const server = details["server"];
20517
+ if (typeof server !== "string" || server.length === 0) return null;
20518
+ return server;
20519
+ }
20520
+
20521
+ // src/sentinel/sentinel-finding-store.ts
20522
+ var SENTINEL_FINDING_NAMESPACE = "_sentinel_findings";
20523
+ var SENTINEL_FINDING_KEY_PREFIX = "finding.";
20524
+ var HKDF_INFO2 = "l2-sentinel-finding-v1";
20525
+ var DEFAULT_SENTINEL_FINDING_RETENTION_DAYS = 30;
20526
+ var MAX_FINDING_BYTES = 256 * 1024;
20527
+ var SentinelFindingStore = class {
20528
+ storage;
20529
+ encryptionKey;
20530
+ fortressId;
20531
+ retentionDays;
20532
+ now;
20533
+ constructor(opts) {
20534
+ this.storage = opts.storage;
20535
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
20536
+ this.fortressId = opts.fortressId;
20537
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_SENTINEL_FINDING_RETENTION_DAYS;
20538
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
20539
+ }
20540
+ /**
20541
+ * Persist a finding. Truncates the operator-visible summary to
20542
+ * SENTINEL_SUMMARY_MAX_CHARS so the dashboard render stays bounded.
20543
+ * Returns the retention deadline so callers can audit it.
20544
+ */
20545
+ async saveFinding(finding) {
20546
+ const truncated = {
20547
+ ...finding,
20548
+ fortress_id: this.fortressId,
20549
+ summary: truncateSummary(finding.summary)
20550
+ };
20551
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
20552
+ const retentionUntil = new Date(this.now().getTime() + retentionMs);
20553
+ const persisted = {
20554
+ version: 1,
20555
+ finding: truncated,
20556
+ retention_until: retentionUntil.toISOString()
20557
+ };
20558
+ const aad = stringToBytes(finding.finding_id);
20559
+ const plaintext = stringToBytes(JSON.stringify(persisted));
20560
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
20561
+ await this.storage.write(
20562
+ SENTINEL_FINDING_NAMESPACE,
20563
+ findingKey(finding.finding_id),
20564
+ stringToBytes(JSON.stringify(envelope))
20565
+ );
20566
+ return persisted.retention_until;
20567
+ }
20568
+ /** Load a single finding by id, or null when absent / corrupted. */
20569
+ async loadFinding(findingId) {
20570
+ let raw;
20571
+ try {
20572
+ raw = await this.storage.read(
20573
+ SENTINEL_FINDING_NAMESPACE,
20574
+ findingKey(findingId)
20575
+ );
20576
+ } catch {
20577
+ return null;
20578
+ }
20579
+ if (!raw) return null;
20580
+ if (raw.length > MAX_FINDING_BYTES) return null;
20581
+ return this.decode(findingId, raw);
20582
+ }
20583
+ /**
20584
+ * List findings, newest first. Optional filters: since (ISO 8601),
20585
+ * severity, sentinel_id, agent_id, limit. Default limit 100.
20586
+ */
20587
+ async listFindings(opts) {
20588
+ const metas = await this.storage.list(
20589
+ SENTINEL_FINDING_NAMESPACE,
20590
+ SENTINEL_FINDING_KEY_PREFIX
20591
+ );
20592
+ const findings = [];
20593
+ for (const meta of metas) {
20594
+ const id = stripKeyPrefix2(meta.key);
20595
+ if (id === null) continue;
20596
+ const raw = await this.storage.read(
20597
+ SENTINEL_FINDING_NAMESPACE,
20598
+ meta.key
20599
+ );
20600
+ if (!raw) continue;
20601
+ if (raw.length > MAX_FINDING_BYTES) continue;
20602
+ const finding = await this.decode(id, raw);
20603
+ if (!finding) continue;
20604
+ if (opts?.since && finding.observed_at < opts.since) continue;
20605
+ if (opts?.severity && finding.severity !== opts.severity) continue;
20606
+ if (opts?.sentinelId && finding.sentinel_id !== opts.sentinelId) continue;
20607
+ if (opts?.agentId && finding.agent_id !== opts.agentId) continue;
20608
+ findings.push(finding);
20609
+ }
20610
+ findings.sort((a, b) => a.observed_at < b.observed_at ? 1 : -1);
20611
+ const limit = opts?.limit ?? 100;
20612
+ return findings.slice(0, limit);
20613
+ }
20614
+ /**
20615
+ * Drop expired findings. Returns the count removed.
20616
+ */
20617
+ async pruneExpired(now) {
20618
+ const cutoff = (now ?? this.now()).toISOString();
20619
+ const metas = await this.storage.list(
20620
+ SENTINEL_FINDING_NAMESPACE,
20621
+ SENTINEL_FINDING_KEY_PREFIX
20622
+ );
20623
+ let pruned = 0;
20624
+ for (const meta of metas) {
20625
+ const id = stripKeyPrefix2(meta.key);
20626
+ if (id === null) continue;
20627
+ const raw = await this.storage.read(
20628
+ SENTINEL_FINDING_NAMESPACE,
20629
+ meta.key
20630
+ );
20631
+ if (!raw) continue;
20632
+ try {
20633
+ const aad = stringToBytes(id);
20634
+ const envelope = JSON.parse(bytesToString(raw));
20635
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
20636
+ const persisted = JSON.parse(
20637
+ bytesToString(plaintext)
20638
+ );
20639
+ if (persisted.retention_until <= cutoff) {
20640
+ await this.storage.delete(SENTINEL_FINDING_NAMESPACE, meta.key);
20641
+ pruned += 1;
20642
+ }
20643
+ } catch {
20644
+ }
20645
+ }
20646
+ return { pruned };
20647
+ }
20648
+ async decode(findingId, raw) {
20649
+ try {
20650
+ const aad = stringToBytes(findingId);
20651
+ const envelope = JSON.parse(bytesToString(raw));
20652
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
20653
+ const persisted = JSON.parse(
20654
+ bytesToString(plaintext)
20655
+ );
20656
+ if (persisted.version !== 1) return null;
20657
+ if (persisted.finding.finding_id !== findingId) return null;
20658
+ if (persisted.finding.fortress_id !== this.fortressId) return null;
20659
+ return persisted.finding;
20660
+ } catch {
20661
+ return null;
20662
+ }
20663
+ }
20664
+ };
20665
+ function findingKey(findingId) {
20666
+ return `${SENTINEL_FINDING_KEY_PREFIX}${findingId}`;
20667
+ }
20668
+ function stripKeyPrefix2(key) {
20669
+ if (!key.startsWith(SENTINEL_FINDING_KEY_PREFIX)) return null;
20670
+ return key.slice(SENTINEL_FINDING_KEY_PREFIX.length);
20671
+ }
20672
+ function truncateSummary(summary) {
20673
+ if (summary.length <= SENTINEL_SUMMARY_MAX_CHARS) return summary;
20674
+ return `${summary.slice(0, SENTINEL_SUMMARY_MAX_CHARS - 3)}...`;
20675
+ }
20676
+
20677
+ // src/sentinel/sentinel-registry.ts
20678
+ var SentinelRegistry = class {
20679
+ catalog = /* @__PURE__ */ new Map();
20680
+ subscribed = /* @__PURE__ */ new Map();
20681
+ register(entry) {
20682
+ if (this.catalog.has(entry.sentinelId)) {
20683
+ throw new Error(
20684
+ `sentinel-registry: ${entry.sentinelId} already registered`
20685
+ );
20686
+ }
20687
+ this.catalog.set(entry.sentinelId, entry);
20688
+ }
20689
+ /**
20690
+ * Available sentinels (catalog view). Operator UI lists this so the
20691
+ * operator can pick what to subscribe to.
20692
+ */
20693
+ listCatalog() {
20694
+ return [...this.catalog.values()].map((entry) => ({
20695
+ sentinelId: entry.sentinelId,
20696
+ description: entry.description
20697
+ }));
20698
+ }
20699
+ /** Currently subscribed sentinel ids. */
20700
+ listSubscribed() {
20701
+ return [...this.subscribed.keys()];
20702
+ }
20703
+ /** Has the fortress opted into this sentinel? */
20704
+ isSubscribed(sentinelId) {
20705
+ return this.subscribed.has(sentinelId);
20706
+ }
20707
+ /**
20708
+ * Subscribe a sentinel to a fortress context. Idempotent: a second
20709
+ * subscribe call on an already-subscribed sentinel returns the
20710
+ * existing instance without re-running `subscribe()`.
20711
+ */
20712
+ async subscribe(sentinelId, context) {
20713
+ const existing = this.subscribed.get(sentinelId);
20714
+ if (existing) return existing;
20715
+ const entry = this.catalog.get(sentinelId);
20716
+ if (!entry) {
20717
+ throw new Error(`sentinel-registry: unknown sentinel ${sentinelId}`);
20718
+ }
20719
+ const instance = entry.factory();
20720
+ await instance.subscribe(context);
20721
+ this.subscribed.set(sentinelId, instance);
20722
+ return instance;
20723
+ }
20724
+ /**
20725
+ * Unsubscribe. Idempotent: unsubscribing an unsubscribed sentinel
20726
+ * returns false without throwing. Returns true when an active
20727
+ * subscription was torn down.
20728
+ */
20729
+ async unsubscribe(sentinelId) {
20730
+ const instance = this.subscribed.get(sentinelId);
20731
+ if (!instance) return false;
20732
+ try {
20733
+ await instance.unsubscribe();
20734
+ } finally {
20735
+ this.subscribed.delete(sentinelId);
20736
+ }
20737
+ return true;
20738
+ }
20739
+ /**
20740
+ * Snapshot of subscribed sentinels for the dispatcher's tick path.
20741
+ * Returned as an array so the dispatcher can iterate without holding
20742
+ * the map under modification.
20743
+ */
20744
+ snapshotSubscribed() {
20745
+ return [...this.subscribed.entries()].map(([sentinelId, sentinel]) => ({
20746
+ sentinelId,
20747
+ sentinel
20748
+ }));
20749
+ }
20750
+ /**
20751
+ * Tear down every subscription. Called by the dispatcher on
20752
+ * fortress-shutdown. Best-effort: a failing unsubscribe does not
20753
+ * abort the rest.
20754
+ */
20755
+ async unsubscribeAll() {
20756
+ const ids = [...this.subscribed.keys()];
20757
+ for (const id of ids) {
20758
+ try {
20759
+ await this.unsubscribe(id);
20760
+ } catch {
20761
+ }
20762
+ }
20763
+ }
20764
+ };
20765
+ var DEFAULT_TICK_INTERVAL_MS = 6e4;
20766
+ var SentinelDispatcher = class {
20767
+ registry;
20768
+ findingStore;
20769
+ auditLog;
20770
+ fortressId;
20771
+ identityId;
20772
+ now;
20773
+ tickIntervalMs;
20774
+ listeners = /* @__PURE__ */ new Set();
20775
+ tickTimer = null;
20776
+ tickInFlight = false;
20777
+ constructor(deps) {
20778
+ this.registry = deps.registry;
20779
+ this.findingStore = deps.findingStore;
20780
+ this.auditLog = deps.auditLog;
20781
+ this.fortressId = deps.fortressId;
20782
+ this.identityId = deps.identityId;
20783
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
20784
+ this.tickIntervalMs = deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
20785
+ }
20786
+ /** Read-only view of the registry. Convenience for route handlers. */
20787
+ getRegistry() {
20788
+ return this.registry;
20789
+ }
20790
+ /** Read-only view of the finding store. Convenience for route handlers. */
20791
+ getFindingStore() {
20792
+ return this.findingStore;
20793
+ }
20794
+ /**
20795
+ * Subscribe an in-process listener. Returns an unsubscribe fn.
20796
+ */
20797
+ onEvent(listener) {
20798
+ this.listeners.add(listener);
20799
+ return () => this.listeners.delete(listener);
20800
+ }
20801
+ /**
20802
+ * Subscribe a sentinel to this fortress + emit the
20803
+ * `sentinel_subscribed` audit event. Wraps `registry.subscribe()` so
20804
+ * the audit emission lives at the dispatcher boundary (the
20805
+ * fortress-aware site).
20806
+ */
20807
+ async subscribeSentinel(sentinelId, contextOverrides) {
20808
+ const context = {
20809
+ fortressId: this.fortressId,
20810
+ auditLog: this.auditLog,
20811
+ now: this.now,
20812
+ ...contextOverrides ?? {}
20813
+ };
20814
+ const sentinel = await this.registry.subscribe(sentinelId, context);
20815
+ this.auditLog.append(
20816
+ "l2",
20817
+ SENTINEL_AUDIT_OPS.SUBSCRIBED,
20818
+ this.identityId,
20819
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
20820
+ );
20821
+ return sentinel;
20822
+ }
20823
+ /**
20824
+ * Unsubscribe + emit `sentinel_unsubscribed`. Returns true when an
20825
+ * active subscription was torn down. Audit fires only on successful
20826
+ * removal.
20827
+ */
20828
+ async unsubscribeSentinel(sentinelId) {
20829
+ const removed = await this.registry.unsubscribe(sentinelId);
20830
+ if (removed) {
20831
+ this.auditLog.append(
20832
+ "l2",
20833
+ SENTINEL_AUDIT_OPS.UNSUBSCRIBED,
20834
+ this.identityId,
20835
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
20836
+ );
20837
+ }
20838
+ return removed;
20839
+ }
20840
+ /**
20841
+ * Run one evaluation pass over every subscribed sentinel. Used by
20842
+ * the auto-tick AND by tests that want a synchronous evaluation
20843
+ * gate. Returns the findings produced this tick (already persisted
20844
+ * + audit-logged + emitted).
20845
+ */
20846
+ async tick() {
20847
+ if (this.tickInFlight) return [];
20848
+ this.tickInFlight = true;
20849
+ try {
20850
+ const subscribed = this.registry.snapshotSubscribed();
20851
+ const findings = [];
20852
+ for (const { sentinelId, sentinel } of subscribed) {
20853
+ try {
20854
+ const tickFindings = await sentinel.evaluate();
20855
+ for (const finding of tickFindings) {
20856
+ const stamped = await this.routeFinding(sentinelId, finding);
20857
+ findings.push(stamped);
20858
+ }
20859
+ } catch (err) {
20860
+ const errorMessage = err instanceof Error ? err.message : String(err);
20861
+ const observedAt = this.now().toISOString();
20862
+ this.auditLog.append(
20863
+ "l2",
20864
+ SENTINEL_AUDIT_OPS.EVALUATION_FAILED,
20865
+ this.identityId,
20866
+ {
20867
+ sentinel_id: sentinelId,
20868
+ fortress_id: this.fortressId,
20869
+ error_message: errorMessage
20870
+ },
20871
+ "failure"
20872
+ );
20873
+ this.emit({
20874
+ type: "evaluation_failed",
20875
+ sentinel_id: sentinelId,
20876
+ error_message: errorMessage,
20877
+ observed_at: observedAt
20878
+ });
20879
+ }
20880
+ }
20881
+ return findings;
20882
+ } finally {
20883
+ this.tickInFlight = false;
20884
+ }
20885
+ }
20886
+ /**
20887
+ * Start the auto-tick loop. No-op when tickIntervalMs is 0 or when
20888
+ * already started. Tests typically leave auto-tick off and call
20889
+ * `tick()` directly.
20890
+ */
20891
+ start() {
20892
+ if (this.tickTimer !== null) return;
20893
+ if (this.tickIntervalMs <= 0) return;
20894
+ this.tickTimer = setInterval(() => {
20895
+ void this.tick();
20896
+ }, this.tickIntervalMs);
20897
+ if (typeof this.tickTimer.unref === "function") {
20898
+ this.tickTimer.unref();
20899
+ }
20900
+ }
20901
+ /** Stop the auto-tick loop. Idempotent. */
20902
+ stop() {
20903
+ if (this.tickTimer === null) return;
20904
+ clearInterval(this.tickTimer);
20905
+ this.tickTimer = null;
20906
+ }
20907
+ /**
20908
+ * Tear down every subscription + stop the tick loop. Called on
20909
+ * fortress shutdown.
20910
+ */
20911
+ async dispose() {
20912
+ this.stop();
20913
+ await this.registry.unsubscribeAll();
20914
+ this.listeners.clear();
20915
+ }
20916
+ async routeFinding(sentinelId, raw) {
20917
+ const stamped = {
20918
+ ...raw,
20919
+ finding_id: raw.finding_id || crypto.randomUUID(),
20920
+ sentinel_id: sentinelId,
20921
+ fortress_id: this.fortressId,
20922
+ observed_at: raw.observed_at || this.now().toISOString()
20923
+ };
20924
+ await this.findingStore.saveFinding(stamped);
20925
+ this.auditLog.append(
20926
+ "l2",
20927
+ SENTINEL_AUDIT_OPS.FINDING_EMITTED,
20928
+ this.identityId,
20929
+ {
20930
+ sentinel_id: sentinelId,
20931
+ finding_id: stamped.finding_id,
20932
+ severity: stamped.severity,
20933
+ ...stamped.agent_id !== void 0 ? { agent_id: stamped.agent_id } : {},
20934
+ evidence_audit_ids: stamped.evidence_audit_ids,
20935
+ fortress_id: this.fortressId
20936
+ }
20937
+ );
20938
+ this.emit({ type: "finding", finding: stamped });
20939
+ return stamped;
20940
+ }
20941
+ emit(event) {
20942
+ for (const listener of this.listeners) {
20943
+ try {
20944
+ listener(event);
20945
+ } catch {
20946
+ }
20947
+ }
20948
+ }
20949
+ };
20950
+
20951
+ // src/sentinel/sentinel.ts
20952
+ var Sentinel = class {
20953
+ /**
20954
+ * Bind the sentinel to a fortress context. Called once on
20955
+ * subscribe. Default implementation stores the context on `this`;
20956
+ * sentinels that need additional setup (e.g. priming a baseline
20957
+ * cache) override.
20958
+ */
20959
+ async subscribe(context) {
20960
+ this.context = context;
20961
+ }
20962
+ /**
20963
+ * Tear down. Default implementation clears the context; subclasses
20964
+ * that hold timers or external handles override.
20965
+ */
20966
+ async unsubscribe() {
20967
+ this.context = void 0;
20968
+ }
20969
+ context;
20970
+ /** Internal helper: assert subscribed before evaluation. */
20971
+ requireContext() {
20972
+ if (!this.context) {
20973
+ throw new Error(
20974
+ `sentinel ${this.sentinelId}: evaluate() called before subscribe()`
20975
+ );
20976
+ }
20977
+ return this.context;
20978
+ }
20979
+ };
20980
+
20981
+ // src/sentinel/sentinels/egress-volume-watcher.ts
20982
+ var EGRESS_VOLUME_SENTINEL_ID = "egress-volume";
20983
+ var WARN_SIGMA = 3;
20984
+ var ALERT_SIGMA = 6;
20985
+ var BASELINE_WINDOWS = 7;
20986
+ var QUERY_LIMIT = 1e4;
20987
+ var EgressVolumeWatcher = class extends Sentinel {
20988
+ sentinelId = EGRESS_VOLUME_SENTINEL_ID;
20989
+ 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.";
20990
+ /** Servers we have already produced an `info` baseline-established finding for. */
20991
+ baselineEstablished = /* @__PURE__ */ new Set();
20992
+ async evaluate() {
20993
+ const ctx = this.requireContext();
20994
+ const now = ctx.now();
20995
+ const windowMs = 24 * 60 * 60 * 1e3;
20996
+ const windowSpanMs = (BASELINE_WINDOWS + 1) * windowMs;
20997
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
20998
+ const queryResult = await ctx.auditLog.query({
20999
+ since: sinceIso,
21000
+ layer: "l2",
21001
+ limit: QUERY_LIMIT
21002
+ });
21003
+ const entries = queryResult.entries.filter(isProxyCallAuditEntry);
21004
+ const byServer = /* @__PURE__ */ new Map();
21005
+ for (const entry of entries) {
21006
+ const server = proxyServerFromAuditEntry(entry);
21007
+ if (server === null) continue;
21008
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
21009
+ if (auditAge < 0) continue;
21010
+ const windowIdx = Math.floor(auditAge / windowMs);
21011
+ if (windowIdx > BASELINE_WINDOWS) continue;
21012
+ let snapshot = byServer.get(server);
21013
+ if (!snapshot) {
21014
+ snapshot = { windows: [] };
21015
+ for (let i = 0; i <= BASELINE_WINDOWS; i += 1) {
21016
+ snapshot.windows.push({ count: 0, evidence_audit_ids: [] });
21017
+ }
21018
+ byServer.set(server, snapshot);
21019
+ }
21020
+ const bucket = snapshot.windows[windowIdx];
21021
+ bucket.count += 1;
21022
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
21023
+ bucket.evidence_audit_ids.push(`${entry.timestamp}:${entry.operation}`);
21024
+ }
21025
+ }
21026
+ const findings = [];
21027
+ for (const [server, snapshot] of byServer.entries()) {
21028
+ const finding = this.evaluateServer(server, snapshot, now);
21029
+ if (finding) findings.push(finding);
21030
+ }
21031
+ return findings;
21032
+ }
21033
+ /** Reset baseline-established memoization. Tests use this between runs. */
21034
+ resetBaselineMemo() {
21035
+ this.baselineEstablished.clear();
21036
+ }
21037
+ evaluateServer(server, snapshot, now) {
21038
+ const currentWindow = snapshot.windows[0];
21039
+ const baselineWindows = snapshot.windows.slice(1);
21040
+ const populatedBaselineWindows = baselineWindows.filter((w) => w.count > 0).length;
21041
+ if (populatedBaselineWindows < BASELINE_WINDOWS) {
21042
+ if (this.baselineEstablished.has(server)) return null;
21043
+ if (populatedBaselineWindows === 0 && currentWindow.count === 0) {
21044
+ return null;
21045
+ }
21046
+ return null;
21047
+ }
21048
+ const baselineCounts = baselineWindows.map((w) => w.count);
21049
+ const mean = baselineCounts.reduce((sum, c) => sum + c, 0) / baselineCounts.length;
21050
+ const variance = baselineCounts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / baselineCounts.length;
21051
+ const stddev = Math.sqrt(variance);
21052
+ const wasEstablished = this.baselineEstablished.has(server);
21053
+ this.baselineEstablished.add(server);
21054
+ if (!wasEstablished) {
21055
+ return {
21056
+ finding_id: "",
21057
+ sentinel_id: this.sentinelId,
21058
+ severity: "info",
21059
+ summary: `egress-volume baseline established for ${server}: mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS} prior days).`,
21060
+ details: {
21061
+ server,
21062
+ baseline_mean: mean,
21063
+ baseline_stddev: stddev,
21064
+ baseline_windows: baselineCounts,
21065
+ current_count: currentWindow.count
21066
+ },
21067
+ observed_at: now.toISOString(),
21068
+ evidence_audit_ids: [],
21069
+ fortress_id: ""
21070
+ };
21071
+ }
21072
+ const warnThreshold = mean + WARN_SIGMA * stddev;
21073
+ const alertThreshold = mean + ALERT_SIGMA * stddev;
21074
+ if (currentWindow.count > alertThreshold) {
21075
+ return this.buildAnomalyFinding(
21076
+ server,
21077
+ snapshot,
21078
+ mean,
21079
+ stddev,
21080
+ now,
21081
+ "alert",
21082
+ ALERT_SIGMA
21083
+ );
21084
+ }
21085
+ if (currentWindow.count > warnThreshold) {
21086
+ return this.buildAnomalyFinding(
21087
+ server,
21088
+ snapshot,
21089
+ mean,
21090
+ stddev,
21091
+ now,
21092
+ "warn",
21093
+ WARN_SIGMA
21094
+ );
21095
+ }
21096
+ return null;
21097
+ }
21098
+ buildAnomalyFinding(server, snapshot, mean, stddev, now, severity, sigma) {
21099
+ const currentWindow = snapshot.windows[0];
21100
+ const ratio = mean === 0 ? Infinity : currentWindow.count / mean;
21101
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21102
+ const summary = `${server} egress is ${ratioStr}: ${currentWindow.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
21103
+ return {
21104
+ finding_id: "",
21105
+ sentinel_id: this.sentinelId,
21106
+ severity,
21107
+ summary,
21108
+ details: {
21109
+ server,
21110
+ current_count: currentWindow.count,
21111
+ baseline_mean: mean,
21112
+ baseline_stddev: stddev,
21113
+ sigma_threshold: sigma,
21114
+ ratio
21115
+ },
21116
+ observed_at: now.toISOString(),
21117
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
21118
+ fortress_id: ""
21119
+ };
21120
+ }
21121
+ };
21122
+
21123
+ // src/sentinel/sentinels/cross-agent-chatter-watcher.ts
21124
+ var CROSS_AGENT_CHATTER_SENTINEL_ID = "cross-agent-chatter";
21125
+ var WARN_SIGMA2 = 3;
21126
+ var ALERT_SIGMA2 = 6;
21127
+ var BASELINE_WINDOWS2 = 7;
21128
+ var QUERY_LIMIT2 = 1e4;
21129
+ var MULTI_NEW_PARTNER_ALERT_THRESHOLD = 3;
21130
+ var OPERATOR_PSEUDO_AGENT = "operator";
21131
+ var HANDOFF_OP = "v1.1_local_handoff";
21132
+ var CROSS_HARNESS_OPS = /* @__PURE__ */ new Set([
21133
+ "cross_harness_approval_aggregated",
21134
+ "cross_harness_approval_resolved"
21135
+ ]);
21136
+ function pairKey(sender, recipient) {
21137
+ return `${sender}|${recipient}`;
21138
+ }
21139
+ function pairFromKey(key) {
21140
+ const idx = key.indexOf("|");
21141
+ return { sender: key.slice(0, idx), recipient: key.slice(idx + 1) };
21142
+ }
21143
+ var CrossAgentChatterWatcher = class extends Sentinel {
21144
+ sentinelId = CROSS_AGENT_CHATTER_SENTINEL_ID;
21145
+ 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).";
21146
+ /** Pair keys we have already produced a baseline-established info finding for. */
21147
+ baselineEstablished = /* @__PURE__ */ new Set();
21148
+ async evaluate() {
21149
+ const ctx = this.requireContext();
21150
+ const now = ctx.now();
21151
+ const windowMs = 24 * 60 * 60 * 1e3;
21152
+ const windowSpanMs = (BASELINE_WINDOWS2 + 1) * windowMs;
21153
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21154
+ const queryResult = await ctx.auditLog.query({
21155
+ since: sinceIso,
21156
+ layer: "l2",
21157
+ limit: QUERY_LIMIT2
21158
+ });
21159
+ const events = extractInterAgentEvents(queryResult.entries);
21160
+ const byPair = /* @__PURE__ */ new Map();
21161
+ for (const event of events) {
21162
+ const auditAgeMs = now.getTime() - event.timestampMs;
21163
+ if (auditAgeMs < 0) continue;
21164
+ const windowIdx = Math.floor(auditAgeMs / windowMs);
21165
+ if (windowIdx > BASELINE_WINDOWS2) continue;
21166
+ const key = pairKey(event.sender, event.recipient);
21167
+ let snap = byPair.get(key);
21168
+ if (!snap) {
21169
+ snap = { windows: [] };
21170
+ for (let i = 0; i <= BASELINE_WINDOWS2; i += 1) {
21171
+ snap.windows.push({ count: 0, evidence_audit_ids: [] });
21172
+ }
21173
+ byPair.set(key, snap);
21174
+ }
21175
+ const bucket = snap.windows[windowIdx];
21176
+ bucket.count += 1;
21177
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
21178
+ bucket.evidence_audit_ids.push(event.auditId);
21179
+ }
21180
+ }
21181
+ const findings = [];
21182
+ for (const [key, snap] of byPair.entries()) {
21183
+ const finding = this.evaluatePair(key, snap, now);
21184
+ if (finding) findings.push(finding);
21185
+ }
21186
+ const newPartnersBySource = computeNewPartners(byPair);
21187
+ for (const [source, partners] of newPartnersBySource.entries()) {
21188
+ const finding = this.buildNewPartnerFinding(source, partners, now);
21189
+ if (finding) findings.push(finding);
21190
+ }
21191
+ return findings;
21192
+ }
21193
+ /** Reset baseline-established memoization. Tests use this between runs. */
21194
+ resetBaselineMemo() {
21195
+ this.baselineEstablished.clear();
21196
+ }
21197
+ evaluatePair(key, snap, now) {
21198
+ const currentWindow = snap.windows[0];
21199
+ const baselineWindows = snap.windows.slice(1);
21200
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
21201
+ if (populated < BASELINE_WINDOWS2) {
21202
+ return null;
21203
+ }
21204
+ const counts = baselineWindows.map((w) => w.count);
21205
+ const mean = counts.reduce((s, c) => s + c, 0) / counts.length;
21206
+ const variance = counts.reduce((s, c) => s + (c - mean) ** 2, 0) / counts.length;
21207
+ const stddev = Math.sqrt(variance);
21208
+ const wasEstablished = this.baselineEstablished.has(key);
21209
+ this.baselineEstablished.add(key);
21210
+ if (!wasEstablished) {
21211
+ const pair = pairFromKey(key);
21212
+ return {
21213
+ finding_id: "",
21214
+ sentinel_id: this.sentinelId,
21215
+ severity: "info",
21216
+ 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).`,
21217
+ details: {
21218
+ sender_agent_id: pair.sender,
21219
+ recipient_agent_id: pair.recipient,
21220
+ baseline_mean: mean,
21221
+ baseline_stddev: stddev,
21222
+ baseline_windows: counts,
21223
+ current_count: currentWindow.count
21224
+ },
21225
+ observed_at: now.toISOString(),
21226
+ evidence_audit_ids: [],
21227
+ fortress_id: ""
21228
+ };
21229
+ }
21230
+ const warnThreshold = mean + WARN_SIGMA2 * stddev;
21231
+ const alertThreshold = mean + ALERT_SIGMA2 * stddev;
21232
+ if (currentWindow.count > alertThreshold) {
21233
+ return this.buildRateSpike(key, snap, mean, stddev, now, "alert", ALERT_SIGMA2);
21234
+ }
21235
+ if (currentWindow.count > warnThreshold) {
21236
+ return this.buildRateSpike(key, snap, mean, stddev, now, "warn", WARN_SIGMA2);
21237
+ }
21238
+ return null;
21239
+ }
21240
+ buildRateSpike(key, snap, mean, stddev, now, severity, sigma) {
21241
+ const pair = pairFromKey(key);
21242
+ const cur = snap.windows[0];
21243
+ const ratio = mean === 0 ? Infinity : cur.count / mean;
21244
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21245
+ 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.`;
21246
+ return {
21247
+ finding_id: "",
21248
+ sentinel_id: this.sentinelId,
21249
+ severity,
21250
+ summary,
21251
+ details: {
21252
+ sender_agent_id: pair.sender,
21253
+ recipient_agent_id: pair.recipient,
21254
+ current_count: cur.count,
21255
+ baseline_mean: mean,
21256
+ baseline_stddev: stddev,
21257
+ sigma_threshold: sigma,
21258
+ ratio
21259
+ },
21260
+ observed_at: now.toISOString(),
21261
+ agent_id: pair.sender,
21262
+ evidence_audit_ids: cur.evidence_audit_ids,
21263
+ fortress_id: ""
21264
+ };
21265
+ }
21266
+ buildNewPartnerFinding(source, info, now) {
21267
+ if (info.partners.length === 0) return null;
21268
+ const severity = info.partners.length >= MULTI_NEW_PARTNER_ALERT_THRESHOLD ? "alert" : "warn";
21269
+ const partnerList = info.partners.join(", ");
21270
+ const baselinePartnerList = info.priorPartners.length === 0 ? "no prior partners" : info.priorPartners.join(", ");
21271
+ 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}.`;
21272
+ return {
21273
+ finding_id: "",
21274
+ sentinel_id: this.sentinelId,
21275
+ severity,
21276
+ summary,
21277
+ details: {
21278
+ sender_agent_id: source,
21279
+ new_partners: info.partners,
21280
+ prior_partners: info.priorPartners,
21281
+ new_partner_count: info.partners.length,
21282
+ multi_new_partner_threshold: MULTI_NEW_PARTNER_ALERT_THRESHOLD
21283
+ },
21284
+ observed_at: now.toISOString(),
21285
+ agent_id: source,
21286
+ evidence_audit_ids: info.evidenceAuditIds,
21287
+ fortress_id: ""
21288
+ };
21289
+ }
21290
+ };
21291
+ function computeNewPartners(byPair) {
21292
+ const currentBySource = /* @__PURE__ */ new Map();
21293
+ const priorBySource = /* @__PURE__ */ new Map();
21294
+ for (const [key, snap] of byPair.entries()) {
21295
+ const { sender, recipient } = pairFromKey(key);
21296
+ if (snap.windows[0] && snap.windows[0].count > 0) {
21297
+ let recipMap = currentBySource.get(sender);
21298
+ if (!recipMap) {
21299
+ recipMap = /* @__PURE__ */ new Map();
21300
+ currentBySource.set(sender, recipMap);
21301
+ }
21302
+ recipMap.set(recipient, snap.windows[0].evidence_audit_ids);
21303
+ }
21304
+ const priorTouched = snap.windows.slice(1).some((w) => w.count > 0);
21305
+ if (priorTouched) {
21306
+ let set = priorBySource.get(sender);
21307
+ if (!set) {
21308
+ set = /* @__PURE__ */ new Set();
21309
+ priorBySource.set(sender, set);
21310
+ }
21311
+ set.add(recipient);
21312
+ }
21313
+ }
21314
+ const out = /* @__PURE__ */ new Map();
21315
+ for (const [sender, recipMap] of currentBySource.entries()) {
21316
+ const prior = priorBySource.get(sender) ?? /* @__PURE__ */ new Set();
21317
+ if (prior.size === 0) {
21318
+ continue;
21319
+ }
21320
+ const newPartners = [];
21321
+ const evidence = [];
21322
+ for (const [recipient, recipEvidence] of recipMap.entries()) {
21323
+ if (!prior.has(recipient)) {
21324
+ newPartners.push(recipient);
21325
+ for (const id of recipEvidence) {
21326
+ if (evidence.length < 50) evidence.push(id);
21327
+ }
21328
+ }
21329
+ }
21330
+ if (newPartners.length === 0) continue;
21331
+ newPartners.sort();
21332
+ out.set(sender, {
21333
+ partners: newPartners,
21334
+ priorPartners: [...prior].sort(),
21335
+ evidenceAuditIds: evidence
21336
+ });
21337
+ }
21338
+ return out;
21339
+ }
21340
+ function extractInterAgentEvents(entries) {
21341
+ const out = [];
21342
+ for (const entry of entries) {
21343
+ const op = entry.operation;
21344
+ if (op === HANDOFF_OP) {
21345
+ const details = entry.details;
21346
+ const sender = optionalString(details, "sender_agent_id");
21347
+ const recipient = optionalString(details, "recipient_agent_id");
21348
+ if (!sender || !recipient || sender === recipient) continue;
21349
+ out.push({
21350
+ sender,
21351
+ recipient,
21352
+ timestampMs: Date.parse(entry.timestamp),
21353
+ auditId: `${entry.timestamp}:${entry.operation}`
21354
+ });
21355
+ continue;
21356
+ }
21357
+ if (CROSS_HARNESS_OPS.has(op)) {
21358
+ const details = entry.details;
21359
+ const sender = optionalString(details, "source_harness") ?? optionalString(details, "source_agent_id");
21360
+ if (!sender) continue;
21361
+ out.push({
21362
+ sender,
21363
+ recipient: OPERATOR_PSEUDO_AGENT,
21364
+ timestampMs: Date.parse(entry.timestamp),
21365
+ auditId: `${entry.timestamp}:${entry.operation}`
21366
+ });
21367
+ }
21368
+ }
21369
+ return out;
21370
+ }
21371
+ function optionalString(details, key) {
21372
+ if (!details) return null;
21373
+ const value = details[key];
21374
+ if (typeof value !== "string" || value.length === 0) return null;
21375
+ return value;
21376
+ }
21377
+
21378
+ // src/sentinel/sentinels/credential-usage-watcher.ts
21379
+ var CREDENTIAL_USAGE_SENTINEL_ID = "credential-usage";
21380
+ var WARN_SIGMA3 = 3;
21381
+ var ALERT_SIGMA3 = 6;
21382
+ var NEW_PAIR_ALERT_COUNT = 3;
21383
+ var BASELINE_WINDOWS3 = 7;
21384
+ var QUERY_LIMIT3 = 2e4;
21385
+ var BROKER_SECRET_READ_OP = "broker_secret_read";
21386
+ var BROKER_TOKEN_ISSUED_OP = "broker_token_issued";
21387
+ var CredentialUsageWatcher = class extends Sentinel {
21388
+ sentinelId = CREDENTIAL_USAGE_SENTINEL_ID;
21389
+ 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.";
21390
+ /**
21391
+ * Memoization of (agent, secret) pairs whose baseline has been
21392
+ * established. Same shape Phi-1 uses to avoid re-emitting `info`
21393
+ * findings on every tick after a baseline first establishes.
21394
+ *
21395
+ * Phi-2 deliberately does NOT emit `info` findings: per-pair
21396
+ * baselines on a busy fortress would be too noisy. The memo is
21397
+ * kept here for parity with Phi-1's reset hook so tests can clear
21398
+ * state between runs.
21399
+ */
21400
+ baselineEstablished = /* @__PURE__ */ new Set();
21401
+ /** Reset memoization. Tests use this between runs. */
21402
+ resetBaselineMemo() {
21403
+ this.baselineEstablished.clear();
21404
+ }
21405
+ async evaluate() {
21406
+ const ctx = this.requireContext();
21407
+ const now = ctx.now();
21408
+ const windowMs = 24 * 60 * 60 * 1e3;
21409
+ const windowSpanMs = (BASELINE_WINDOWS3 + 1) * windowMs;
21410
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21411
+ const queryResult = await ctx.auditLog.query({
21412
+ since: sinceIso,
21413
+ layer: "l3",
21414
+ limit: QUERY_LIMIT3
21415
+ });
21416
+ const entries = queryResult.entries.filter(isCredentialAuditEntry);
21417
+ const byPair = /* @__PURE__ */ new Map();
21418
+ const byAgent = /* @__PURE__ */ new Map();
21419
+ for (const entry of entries) {
21420
+ const agentId = extractAgentId(entry);
21421
+ const secretId = extractSecretId(entry);
21422
+ if (agentId === null || secretId === null) continue;
21423
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
21424
+ if (auditAge < 0) continue;
21425
+ const windowIdx = Math.floor(auditAge / windowMs);
21426
+ if (windowIdx > BASELINE_WINDOWS3) continue;
21427
+ const pairKey2 = `${agentId}\0${secretId}`;
21428
+ let pairSnapshot = byPair.get(pairKey2);
21429
+ if (!pairSnapshot) {
21430
+ pairSnapshot = {
21431
+ windows: Array.from({ length: BASELINE_WINDOWS3 + 1 }, () => ({
21432
+ count: 0,
21433
+ evidence_audit_ids: []
21434
+ }))
21435
+ };
21436
+ byPair.set(pairKey2, pairSnapshot);
21437
+ }
21438
+ const bucket = pairSnapshot.windows[windowIdx];
21439
+ bucket.count += 1;
21440
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
21441
+ bucket.evidence_audit_ids.push(
21442
+ `${entry.timestamp}:${entry.operation}`
21443
+ );
21444
+ }
21445
+ let agentState = byAgent.get(agentId);
21446
+ if (!agentState) {
21447
+ agentState = {
21448
+ currentSecrets: /* @__PURE__ */ new Set(),
21449
+ currentEvidence: [],
21450
+ baselineSecretsByWindow: Array.from(
21451
+ { length: BASELINE_WINDOWS3 },
21452
+ () => /* @__PURE__ */ new Set()
21453
+ ),
21454
+ baselinePopulatedWindows: 0
21455
+ };
21456
+ byAgent.set(agentId, agentState);
21457
+ }
21458
+ if (windowIdx === 0) {
21459
+ agentState.currentSecrets.add(secretId);
21460
+ if (agentState.currentEvidence.length < 50) {
21461
+ agentState.currentEvidence.push(
21462
+ `${entry.timestamp}:${entry.operation}`
21463
+ );
21464
+ }
21465
+ } else {
21466
+ const baselineIdx = windowIdx - 1;
21467
+ agentState.baselineSecretsByWindow[baselineIdx].add(secretId);
21468
+ }
21469
+ }
21470
+ const findings = [];
21471
+ for (const [pairKey2, snapshot] of byPair.entries()) {
21472
+ const [agentId, secretId] = pairKey2.split("\0");
21473
+ const finding = this.evaluateRateSpike(
21474
+ agentId,
21475
+ secretId,
21476
+ snapshot,
21477
+ now
21478
+ );
21479
+ if (finding) findings.push(finding);
21480
+ }
21481
+ for (const [agentId, agentState] of byAgent.entries()) {
21482
+ agentState.baselinePopulatedWindows = agentState.baselineSecretsByWindow.filter((s) => s.size > 0).length;
21483
+ const finding = this.evaluateNewPairs(agentId, agentState, now);
21484
+ if (finding) findings.push(finding);
21485
+ }
21486
+ return findings;
21487
+ }
21488
+ evaluateRateSpike(agentId, secretId, snapshot, now) {
21489
+ const currentWindow = snapshot.windows[0];
21490
+ const baselineWindows = snapshot.windows.slice(1);
21491
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
21492
+ if (populated < BASELINE_WINDOWS3) {
21493
+ return null;
21494
+ }
21495
+ const counts = baselineWindows.map((w) => w.count);
21496
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
21497
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
21498
+ const stddev = Math.sqrt(variance);
21499
+ const pairKey2 = `${agentId}\0${secretId}`;
21500
+ this.baselineEstablished.add(pairKey2);
21501
+ const warnThreshold = mean + WARN_SIGMA3 * stddev;
21502
+ const alertThreshold = mean + ALERT_SIGMA3 * stddev;
21503
+ if (currentWindow.count > alertThreshold) {
21504
+ return this.buildRateFinding(
21505
+ agentId,
21506
+ secretId,
21507
+ currentWindow,
21508
+ mean,
21509
+ stddev,
21510
+ now,
21511
+ "alert",
21512
+ ALERT_SIGMA3
21513
+ );
21514
+ }
21515
+ if (currentWindow.count > warnThreshold) {
21516
+ return this.buildRateFinding(
21517
+ agentId,
21518
+ secretId,
21519
+ currentWindow,
21520
+ mean,
21521
+ stddev,
21522
+ now,
21523
+ "warn",
21524
+ WARN_SIGMA3
21525
+ );
21526
+ }
21527
+ return null;
21528
+ }
21529
+ buildRateFinding(agentId, secretId, currentWindow, mean, stddev, now, severity, sigma) {
21530
+ const ratio = mean === 0 ? Number.POSITIVE_INFINITY : currentWindow.count / mean;
21531
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21532
+ const summary = `${agentId} agent used ${secretId} ${currentWindow.count} times in last 24h, baseline ${mean.toFixed(1)} +/- ${stddev.toFixed(1)} (${ratioStr}). Crossed +${sigma} sigma threshold.`;
21533
+ return {
21534
+ finding_id: "",
21535
+ sentinel_id: this.sentinelId,
21536
+ severity,
21537
+ agent_id: agentId,
21538
+ summary,
21539
+ details: {
21540
+ agent_id: agentId,
21541
+ secret_id: secretId,
21542
+ current_count: currentWindow.count,
21543
+ baseline_mean: mean,
21544
+ baseline_stddev: stddev,
21545
+ sigma_threshold: sigma,
21546
+ ratio: Number.isFinite(ratio) ? ratio : null
21547
+ },
21548
+ observed_at: now.toISOString(),
21549
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
21550
+ fortress_id: ""
21551
+ };
21552
+ }
21553
+ evaluateNewPairs(agentId, state, now) {
21554
+ if (state.baselinePopulatedWindows < BASELINE_WINDOWS3) {
21555
+ return null;
21556
+ }
21557
+ if (state.currentSecrets.size < 2) return null;
21558
+ const currentPairs = enumerateUnorderedPairs(state.currentSecrets);
21559
+ const historicalPairs = /* @__PURE__ */ new Set();
21560
+ for (const secretSet of state.baselineSecretsByWindow) {
21561
+ for (const pair of enumerateUnorderedPairs(secretSet)) {
21562
+ historicalPairs.add(pair);
21563
+ }
21564
+ }
21565
+ const newPairs = [];
21566
+ for (const pair of currentPairs) {
21567
+ if (historicalPairs.has(pair)) continue;
21568
+ const [a, b] = pair.split("\0");
21569
+ newPairs.push([a, b]);
21570
+ }
21571
+ if (newPairs.length === 0) return null;
21572
+ const severity = newPairs.length >= NEW_PAIR_ALERT_COUNT ? "alert" : "warn";
21573
+ const summary = buildNewPairSummary(agentId, newPairs);
21574
+ return {
21575
+ finding_id: "",
21576
+ sentinel_id: this.sentinelId,
21577
+ severity,
21578
+ agent_id: agentId,
21579
+ summary,
21580
+ details: {
21581
+ agent_id: agentId,
21582
+ new_pairs: newPairs,
21583
+ new_pair_count: newPairs.length,
21584
+ historical_pair_count: historicalPairs.size,
21585
+ current_pair_count: currentPairs.size
21586
+ },
21587
+ observed_at: now.toISOString(),
21588
+ evidence_audit_ids: state.currentEvidence,
21589
+ fortress_id: ""
21590
+ };
21591
+ }
21592
+ };
21593
+ function isCredentialAuditEntry(entry) {
21594
+ if (entry.result !== "success") return false;
21595
+ return entry.operation === BROKER_SECRET_READ_OP || entry.operation === BROKER_TOKEN_ISSUED_OP;
21596
+ }
21597
+ function extractAgentId(entry) {
21598
+ const details = entry.details;
21599
+ if (!details) return null;
21600
+ const agent = details["agent"];
21601
+ return typeof agent === "string" && agent.length > 0 ? agent : null;
21602
+ }
21603
+ function extractSecretId(entry) {
21604
+ const details = entry.details;
21605
+ if (!details) return null;
21606
+ const secret = details["secret"];
21607
+ return typeof secret === "string" && secret.length > 0 ? secret : null;
21608
+ }
21609
+ function enumerateUnorderedPairs(secrets) {
21610
+ const out = /* @__PURE__ */ new Set();
21611
+ const arr = [...secrets].sort();
21612
+ for (let i = 0; i < arr.length; i += 1) {
21613
+ for (let j = i + 1; j < arr.length; j += 1) {
21614
+ out.add(`${arr[i]}\0${arr[j]}`);
21615
+ }
21616
+ }
21617
+ return out;
21618
+ }
21619
+ function buildNewPairSummary(agentId, newPairs) {
21620
+ const first = newPairs[0];
21621
+ if (newPairs.length === 1) {
21622
+ return `${agentId} agent used ${first[0]} and ${first[1]} together for the first time today. This combination does not appear in historical sessions.`;
21623
+ }
21624
+ 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.`;
21625
+ }
21626
+
21627
+ // src/sentinel/sentinels/suspicious-tool-call-detector.ts
21628
+ var SUSPICIOUS_TOOL_CALL_SENTINEL_ID = "suspicious-tool-call";
21629
+ var GATE_PREFIXES = [
21630
+ "gate_allow:",
21631
+ "gate_allow_proxy:",
21632
+ "gate_deny:",
21633
+ "gate_unclassified:"
21634
+ ];
21635
+ var WARN_SIGMA4 = 3;
21636
+ var ALERT_SIGMA4 = 6;
21637
+ var BASELINE_WINDOWS4 = 7;
21638
+ var ALERT_NOVEL_COMBINATIONS = 2;
21639
+ var TASK_WINDOW_MS = 60 * 60 * 1e3;
21640
+ var TRUNCATION_WARN_THRESHOLD = 5;
21641
+ var QUERY_LIMIT4 = 1e4;
21642
+ var SIGNATURE_PATTERNS = {
21643
+ /** >=5 percent-encoded sequences in a single visible value. */
21644
+ urlEncodedThreshold: 5,
21645
+ /** >=40 contiguous base64 chars in a single visible value. */
21646
+ base64MinRun: 40,
21647
+ /** Shell metacharacter set. */
21648
+ shellMetacharRegex: /(?:&&|\|\||;|\$\(|`|\|\s)/
21649
+ };
21650
+ var SuspiciousToolCallDetector = class extends Sentinel {
21651
+ sentinelId = SUSPICIOUS_TOOL_CALL_SENTINEL_ID;
21652
+ 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.";
21653
+ /** Servers we have already produced an `info` baseline-established finding for. */
21654
+ baselineEstablished = /* @__PURE__ */ new Set();
21655
+ /** Memoized known novel-combination keys (sorted-tools-csv). */
21656
+ knownCombinations = /* @__PURE__ */ new Set();
21657
+ /** Tasks observed where a novel combination already produced a finding. */
21658
+ novelCombinationsReported = /* @__PURE__ */ new Set();
21659
+ async evaluate() {
21660
+ const ctx = this.requireContext();
21661
+ const now = ctx.now();
21662
+ const dayMs = 24 * 60 * 60 * 1e3;
21663
+ const windowSpanMs = (BASELINE_WINDOWS4 + 1) * dayMs;
21664
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21665
+ const queryResult = await ctx.auditLog.query({
21666
+ since: sinceIso,
21667
+ layer: "l2",
21668
+ limit: QUERY_LIMIT4
21669
+ });
21670
+ const observations = [];
21671
+ for (const entry of queryResult.entries) {
21672
+ const obs = this.observationFromEntry(entry);
21673
+ if (obs && obs.ts <= now.getTime()) observations.push(obs);
21674
+ }
21675
+ if (observations.length === 0) return [];
21676
+ const findings = [];
21677
+ const layerAFindings = await this.runLayerA(observations, now, ctx);
21678
+ findings.push(...layerAFindings);
21679
+ const layerBFindings = this.runLayerB(observations, now);
21680
+ findings.push(...layerBFindings);
21681
+ const layerCFindings = this.runLayerC(observations, now);
21682
+ findings.push(...layerCFindings);
21683
+ return findings;
21684
+ }
21685
+ /** Reset memoization between test runs. Mirrors Phi-1's reset hook. */
21686
+ resetMemo() {
21687
+ this.baselineEstablished.clear();
21688
+ this.knownCombinations.clear();
21689
+ this.novelCombinationsReported.clear();
21690
+ }
21691
+ // ── Layer A ───────────────────────────────────────────────────────
21692
+ async runLayerA(observations, now, ctx) {
21693
+ const dayMs = 24 * 60 * 60 * 1e3;
21694
+ const recent = observations.filter(
21695
+ (o) => now.getTime() - o.ts <= dayMs
21696
+ );
21697
+ if (recent.length === 0) return [];
21698
+ const findings = [];
21699
+ const historical = observations.filter(
21700
+ (o) => now.getTime() - o.ts > dayMs
21701
+ );
21702
+ const perTool = /* @__PURE__ */ new Map();
21703
+ const ensureTool = (tool) => {
21704
+ let w = perTool.get(tool);
21705
+ if (!w) {
21706
+ w = {
21707
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
21708
+ count: 0,
21709
+ evidenceIds: []
21710
+ })),
21711
+ knownSignatures: /* @__PURE__ */ new Set()
21712
+ };
21713
+ perTool.set(tool, w);
21714
+ }
21715
+ return w;
21716
+ };
21717
+ for (const o of historical) {
21718
+ ensureTool(o.tool).knownSignatures.add(this.signatureOf(o.argsSummary));
21719
+ }
21720
+ const classify = this.classifyHandle(ctx);
21721
+ for (const obs of recent) {
21722
+ const matches = this.matchSignatures(obs);
21723
+ if (matches.length === 0) continue;
21724
+ const ambiguous = matches.every((m) => m === "base64_chunk");
21725
+ if (ambiguous && classify) {
21726
+ const verdict = await this.consultClassifier(classify, obs);
21727
+ if (verdict !== "suspicious") continue;
21728
+ }
21729
+ findings.push(
21730
+ this.buildLayerAFinding(obs, matches, now, classify ? "llm-assist" : "rule-based")
21731
+ );
21732
+ }
21733
+ for (const obs of recent) {
21734
+ const tool = obs.tool;
21735
+ const sig = this.signatureOf(obs.argsSummary);
21736
+ const known = perTool.get(tool)?.knownSignatures;
21737
+ if (known && known.size > 0 && !known.has(sig)) {
21738
+ findings.push(
21739
+ this.buildNovelSignatureFinding(obs, sig, now)
21740
+ );
21741
+ }
21742
+ }
21743
+ return findings;
21744
+ }
21745
+ matchSignatures(obs) {
21746
+ const out = [];
21747
+ const truncCount = countTruncatedValues(obs.argsSummary);
21748
+ if (truncCount >= TRUNCATION_WARN_THRESHOLD) out.push("truncation_burst");
21749
+ let urlBlob = false;
21750
+ let base64Blob = false;
21751
+ let shellChars = false;
21752
+ for (const value of Object.values(obs.argsSummary)) {
21753
+ if (typeof value !== "string") continue;
21754
+ if (countUrlEncoded(value) >= SIGNATURE_PATTERNS.urlEncodedThreshold) {
21755
+ urlBlob = true;
21756
+ }
21757
+ if (longestBase64Run(value) >= SIGNATURE_PATTERNS.base64MinRun) {
21758
+ base64Blob = true;
21759
+ }
21760
+ if (SIGNATURE_PATTERNS.shellMetacharRegex.test(value)) {
21761
+ shellChars = true;
21762
+ }
21763
+ }
21764
+ if (urlBlob) out.push("url_encoded_blob");
21765
+ if (base64Blob) out.push("base64_chunk");
21766
+ if (shellChars) out.push("shell_metachar");
21767
+ return out;
21768
+ }
21769
+ signatureOf(argsSummary) {
21770
+ return Object.keys(argsSummary).sort().join(",");
21771
+ }
21772
+ buildLayerAFinding(obs, matches, now, detectionPath) {
21773
+ const severity = matches.includes("shell_metachar") ? "alert" : "warn";
21774
+ const summary = `${obs.tool}: tool-call argument matches signature ${matches.join(", ")} (${detectionPath}).`;
21775
+ return {
21776
+ finding_id: "",
21777
+ sentinel_id: this.sentinelId,
21778
+ severity,
21779
+ summary: truncateSummary2(summary),
21780
+ details: {
21781
+ layer: "A",
21782
+ tool: obs.tool,
21783
+ proxy: obs.proxy,
21784
+ signatures: matches,
21785
+ detection_path: detectionPath
21786
+ },
21787
+ observed_at: now.toISOString(),
21788
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
21789
+ fortress_id: ""
20231
21790
  };
20232
- const aad = stringToBytes(aggregatorId);
20233
- const plaintext = stringToBytes(JSON.stringify(bundle));
20234
- const envelope = encrypt(plaintext, this.encryptionKey, aad);
20235
- await this.storage.write(
20236
- AGGREGATOR_PAYLOAD_NAMESPACE,
20237
- payloadKey(aggregatorId),
20238
- stringToBytes(JSON.stringify(envelope))
20239
- );
20240
- return bundle.retention_until;
20241
21791
  }
20242
- /**
20243
- * Read the persisted payload for the aggregator_id. Returns null if no
20244
- * bundle exists, the bundle is corrupted, or AAD binding fails.
20245
- */
20246
- async loadPayload(aggregatorId) {
20247
- const key = payloadKey(aggregatorId);
20248
- let raw;
20249
- try {
20250
- raw = await this.storage.read(AGGREGATOR_PAYLOAD_NAMESPACE, key);
20251
- } catch {
21792
+ buildNovelSignatureFinding(obs, signature, now) {
21793
+ return {
21794
+ finding_id: "",
21795
+ sentinel_id: this.sentinelId,
21796
+ severity: "warn",
21797
+ summary: truncateSummary2(
21798
+ `${obs.tool}: novel argument-key signature observed (${signature || "<no-args>"}).`
21799
+ ),
21800
+ details: {
21801
+ layer: "A",
21802
+ tool: obs.tool,
21803
+ proxy: obs.proxy,
21804
+ signatures: ["novel_signature"],
21805
+ detection_path: "rule-based",
21806
+ novel_signature: signature
21807
+ },
21808
+ observed_at: now.toISOString(),
21809
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
21810
+ fortress_id: ""
21811
+ };
21812
+ }
21813
+ // ── Layer B ───────────────────────────────────────────────────────
21814
+ runLayerB(observations, now) {
21815
+ const dayMs = 24 * 60 * 60 * 1e3;
21816
+ const perTool = /* @__PURE__ */ new Map();
21817
+ for (const obs of observations) {
21818
+ const ageMs = now.getTime() - obs.ts;
21819
+ if (ageMs < 0) continue;
21820
+ const windowIdx = Math.floor(ageMs / dayMs);
21821
+ if (windowIdx > BASELINE_WINDOWS4) continue;
21822
+ let w = perTool.get(obs.tool);
21823
+ if (!w) {
21824
+ w = {
21825
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
21826
+ count: 0,
21827
+ evidenceIds: []
21828
+ })),
21829
+ knownSignatures: /* @__PURE__ */ new Set()
21830
+ };
21831
+ perTool.set(obs.tool, w);
21832
+ }
21833
+ const bucket = w.windows[windowIdx];
21834
+ bucket.count += 1;
21835
+ if (windowIdx === 0 && bucket.evidenceIds.length < 50) {
21836
+ bucket.evidenceIds.push(`${obs.entry.timestamp}:${obs.entry.operation}`);
21837
+ }
21838
+ }
21839
+ const findings = [];
21840
+ for (const [tool, w] of perTool.entries()) {
21841
+ const f = this.evaluateToolFrequency(tool, w, now);
21842
+ if (f) findings.push(f);
21843
+ }
21844
+ return findings;
21845
+ }
21846
+ evaluateToolFrequency(tool, w, now) {
21847
+ const current = w.windows[0];
21848
+ const baseline = w.windows.slice(1);
21849
+ const populated = baseline.filter((b) => b.count > 0).length;
21850
+ if (populated < BASELINE_WINDOWS4) {
21851
+ this.baselineEstablished.add(tool);
20252
21852
  return null;
20253
21853
  }
20254
- if (!raw) return null;
20255
- if (raw.length > MAX_BUNDLE_BYTES2) return null;
20256
- try {
20257
- const envelope = JSON.parse(bytesToString(raw));
20258
- const aad = stringToBytes(aggregatorId);
20259
- const plaintext = decrypt(envelope, this.encryptionKey, aad);
20260
- const parsed = JSON.parse(
20261
- bytesToString(plaintext)
21854
+ const counts = baseline.map((b) => b.count);
21855
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
21856
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
21857
+ const stddev = Math.sqrt(variance);
21858
+ const wasEstablished = this.baselineEstablished.has(tool);
21859
+ this.baselineEstablished.add(tool);
21860
+ if (!wasEstablished) {
21861
+ return {
21862
+ finding_id: "",
21863
+ sentinel_id: this.sentinelId,
21864
+ severity: "info",
21865
+ summary: truncateSummary2(
21866
+ `${tool}: tool-call baseline established (mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} over ${BASELINE_WINDOWS4} prior days).`
21867
+ ),
21868
+ details: {
21869
+ layer: "B",
21870
+ tool,
21871
+ baseline_mean: mean,
21872
+ baseline_stddev: stddev,
21873
+ baseline_counts: counts,
21874
+ current_count: current.count
21875
+ },
21876
+ observed_at: now.toISOString(),
21877
+ evidence_audit_ids: [],
21878
+ fortress_id: ""
21879
+ };
21880
+ }
21881
+ const warnT = mean + WARN_SIGMA4 * stddev;
21882
+ const alertT = mean + ALERT_SIGMA4 * stddev;
21883
+ if (current.count > alertT) {
21884
+ return this.buildLayerBAnomaly(
21885
+ tool,
21886
+ current,
21887
+ mean,
21888
+ stddev,
21889
+ ALERT_SIGMA4,
21890
+ "alert",
21891
+ now
21892
+ );
21893
+ }
21894
+ if (current.count > warnT) {
21895
+ return this.buildLayerBAnomaly(
21896
+ tool,
21897
+ current,
21898
+ mean,
21899
+ stddev,
21900
+ WARN_SIGMA4,
21901
+ "warn",
21902
+ now
20262
21903
  );
20263
- if (parsed.version !== 1) return null;
20264
- if (parsed.aggregator_id !== aggregatorId) return null;
20265
- return parsed.payload;
20266
- } catch {
20267
- return null;
20268
21904
  }
21905
+ return null;
20269
21906
  }
20270
- /**
20271
- * Delete the persisted payload. Returns true when a bundle was removed,
20272
- * false when none existed.
20273
- */
20274
- async deletePayload(aggregatorId) {
20275
- const key = payloadKey(aggregatorId);
20276
- const existed = await this.storage.exists(
20277
- AGGREGATOR_PAYLOAD_NAMESPACE,
20278
- key
20279
- );
20280
- if (!existed) return false;
20281
- try {
20282
- await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, key);
20283
- } catch {
20284
- return false;
21907
+ buildLayerBAnomaly(tool, current, mean, stddev, sigma, severity, now) {
21908
+ const ratio = mean === 0 ? Infinity : current.count / mean;
21909
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
21910
+ return {
21911
+ finding_id: "",
21912
+ sentinel_id: this.sentinelId,
21913
+ severity,
21914
+ summary: truncateSummary2(
21915
+ `${tool}: tool-call rate is ${ratioStr}: ${current.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma.`
21916
+ ),
21917
+ details: {
21918
+ layer: "B",
21919
+ tool,
21920
+ current_count: current.count,
21921
+ baseline_mean: mean,
21922
+ baseline_stddev: stddev,
21923
+ sigma_threshold: sigma,
21924
+ ratio
21925
+ },
21926
+ observed_at: now.toISOString(),
21927
+ evidence_audit_ids: current.evidenceIds,
21928
+ fortress_id: ""
21929
+ };
21930
+ }
21931
+ // ── Layer C ───────────────────────────────────────────────────────
21932
+ runLayerC(observations, now) {
21933
+ const dayMs = 24 * 60 * 60 * 1e3;
21934
+ const sorted = [...observations].sort((a, b) => a.ts - b.ts);
21935
+ const tasks = [];
21936
+ for (const obs of sorted) {
21937
+ const last = tasks[tasks.length - 1];
21938
+ if (!last || obs.ts - last.startTs > TASK_WINDOW_MS) {
21939
+ tasks.push({ startTs: obs.ts, tools: [obs.tool] });
21940
+ continue;
21941
+ }
21942
+ if (!last.tools.includes(obs.tool)) last.tools.push(obs.tool);
20285
21943
  }
20286
- return true;
21944
+ const recentTaskKeys = [];
21945
+ const findings = [];
21946
+ for (const task of tasks) {
21947
+ const ageMs = now.getTime() - task.startTs;
21948
+ const key = task.tools.slice().sort().join(",");
21949
+ if (ageMs > dayMs) {
21950
+ this.knownCombinations.add(key);
21951
+ continue;
21952
+ }
21953
+ if (task.tools.length < 2) continue;
21954
+ if (!this.knownCombinations.has(key)) {
21955
+ this.knownCombinations.add(key);
21956
+ if (!this.novelCombinationsReported.has(key)) {
21957
+ this.novelCombinationsReported.add(key);
21958
+ recentTaskKeys.push(key);
21959
+ findings.push(
21960
+ this.buildLayerCFinding(task, key, "warn", now)
21961
+ );
21962
+ }
21963
+ }
21964
+ }
21965
+ if (recentTaskKeys.length >= ALERT_NOVEL_COMBINATIONS) {
21966
+ const aggregate = {
21967
+ finding_id: "",
21968
+ sentinel_id: this.sentinelId,
21969
+ severity: "alert",
21970
+ summary: truncateSummary2(
21971
+ `multi-novel-combination: ${recentTaskKeys.length} novel tool-permission combinations within last 24h.`
21972
+ ),
21973
+ details: {
21974
+ layer: "C",
21975
+ novel_combinations: recentTaskKeys
21976
+ },
21977
+ observed_at: now.toISOString(),
21978
+ evidence_audit_ids: [],
21979
+ fortress_id: ""
21980
+ };
21981
+ findings.push(aggregate);
21982
+ }
21983
+ return findings;
20287
21984
  }
20288
- /**
20289
- * Drop expired payload bundles. Returns the count of bundles pruned.
20290
- * Caller wires this into the cocoon-unlock initialization path.
20291
- */
20292
- async pruneExpired(now) {
20293
- const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
20294
- const entries = await this.storage.list(
20295
- AGGREGATOR_PAYLOAD_NAMESPACE,
20296
- AGGREGATOR_PAYLOAD_KEY_PREFIX
20297
- );
20298
- let pruned = 0;
20299
- for (const meta of entries) {
20300
- const aggregatorId = stripKeyPrefix(meta.key);
20301
- if (aggregatorId === null) continue;
20302
- const raw = await this.storage.read(
20303
- AGGREGATOR_PAYLOAD_NAMESPACE,
20304
- meta.key
20305
- );
20306
- if (!raw) continue;
21985
+ buildLayerCFinding(task, key, severity, now) {
21986
+ return {
21987
+ finding_id: "",
21988
+ sentinel_id: this.sentinelId,
21989
+ severity,
21990
+ summary: truncateSummary2(
21991
+ `novel-permission-combination: tools=[${task.tools.join(",")}] observed in single task burst (${task.tools.length} distinct tools).`
21992
+ ),
21993
+ details: {
21994
+ layer: "C",
21995
+ combination_key: key,
21996
+ tools: task.tools,
21997
+ task_started_at: new Date(task.startTs).toISOString()
21998
+ },
21999
+ observed_at: now.toISOString(),
22000
+ evidence_audit_ids: [],
22001
+ fortress_id: ""
22002
+ };
22003
+ }
22004
+ // ── LLM-assist ────────────────────────────────────────────────────
22005
+ classifyHandle(ctx) {
22006
+ const selector = ctx.substrateSelector;
22007
+ if (!selector) return null;
22008
+ const fn = selector.invokeClassify;
22009
+ if (typeof fn !== "function") return null;
22010
+ return async (items) => {
20307
22011
  try {
20308
- const envelope = JSON.parse(bytesToString(raw));
20309
- const aad = stringToBytes(aggregatorId);
20310
- const plaintext = decrypt(envelope, this.encryptionKey, aad);
20311
- const parsed = JSON.parse(
20312
- bytesToString(plaintext)
20313
- );
20314
- if (parsed.retention_until <= cutoff) {
20315
- await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, meta.key);
20316
- pruned += 1;
22012
+ const resp = await fn.call(selector, "sentinel-scoring", {
22013
+ kind: "classify",
22014
+ items,
22015
+ categories: ["benign", "suspicious"]
22016
+ });
22017
+ if (resp.failureClass) return { kind: "failure", message: "substrate failure" };
22018
+ if (resp.body.kind === "classify") {
22019
+ return { kind: "classify", results: resp.body.results };
20317
22020
  }
22021
+ return { kind: "failure", message: resp.body.message };
20318
22022
  } catch {
22023
+ return null;
22024
+ }
22025
+ };
22026
+ }
22027
+ async consultClassifier(classify, obs) {
22028
+ const item = JSON.stringify({
22029
+ tool: obs.tool,
22030
+ proxy: obs.proxy,
22031
+ args_summary: obs.argsSummary
22032
+ });
22033
+ const result = await classify([item]);
22034
+ if (!result || result.kind !== "classify") return "unknown";
22035
+ const top = result.results[0];
22036
+ if (!top) return "unknown";
22037
+ if (top.category === "suspicious" && top.confidence >= 0.5) {
22038
+ return "suspicious";
22039
+ }
22040
+ if (top.category === "benign") return "benign";
22041
+ return "unknown";
22042
+ }
22043
+ // ── helpers ───────────────────────────────────────────────────────
22044
+ observationFromEntry(entry) {
22045
+ const op = entry.operation;
22046
+ let tool = null;
22047
+ let proxy = false;
22048
+ for (const prefix of GATE_PREFIXES) {
22049
+ if (op.startsWith(prefix)) {
22050
+ tool = op.slice(prefix.length);
22051
+ proxy = prefix === "gate_allow_proxy:";
22052
+ break;
20319
22053
  }
20320
22054
  }
20321
- return { pruned };
22055
+ if (!tool) return null;
22056
+ const ts = Date.parse(entry.timestamp);
22057
+ if (!Number.isFinite(ts)) return null;
22058
+ const argsSummary = extractArgsSummary(entry.details);
22059
+ return { tool, proxy, ts, entry, argsSummary };
20322
22060
  }
20323
22061
  };
20324
- function payloadKey(aggregatorId) {
20325
- return `${AGGREGATOR_PAYLOAD_KEY_PREFIX}${aggregatorId}`;
22062
+ function countTruncatedValues(args) {
22063
+ let n = 0;
22064
+ for (const v of Object.values(args)) {
22065
+ if (typeof v === "string" && v.endsWith("...")) n += 1;
22066
+ }
22067
+ return n;
20326
22068
  }
20327
- function stripKeyPrefix(key) {
20328
- if (!key.startsWith(AGGREGATOR_PAYLOAD_KEY_PREFIX)) return null;
20329
- return key.slice(AGGREGATOR_PAYLOAD_KEY_PREFIX.length);
22069
+ function countUrlEncoded(value) {
22070
+ const matches = value.match(/%[0-9a-fA-F]{2}/g);
22071
+ return matches ? matches.length : 0;
22072
+ }
22073
+ function longestBase64Run(value) {
22074
+ const matches = value.match(/[A-Za-z0-9+/=]{40,}/g);
22075
+ if (!matches) return 0;
22076
+ return matches.reduce((max, m) => m.length > max ? m.length : max, 0);
22077
+ }
22078
+ function extractArgsSummary(details) {
22079
+ if (!details) return {};
22080
+ const summary = details["args_summary"];
22081
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
22082
+ return summary;
22083
+ }
22084
+ return {};
22085
+ }
22086
+ function truncateSummary2(s) {
22087
+ return s.length > 240 ? s.slice(0, 237) + "..." : s;
22088
+ }
22089
+
22090
+ // src/sentinel/sentinels/index.ts
22091
+ var PHI1_BASELINE_CATALOG = [
22092
+ {
22093
+ sentinelId: EGRESS_VOLUME_SENTINEL_ID,
22094
+ description: "Watches outbound proxy-call volume per upstream server and surfaces anomalous spikes against a rolling 7-day baseline.",
22095
+ factory: () => new EgressVolumeWatcher()
22096
+ },
22097
+ {
22098
+ sentinelId: CROSS_AGENT_CHATTER_SENTINEL_ID,
22099
+ 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.",
22100
+ factory: () => new CrossAgentChatterWatcher()
22101
+ },
22102
+ {
22103
+ sentinelId: CREDENTIAL_USAGE_SENTINEL_ID,
22104
+ 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.",
22105
+ factory: () => new CredentialUsageWatcher()
22106
+ },
22107
+ {
22108
+ sentinelId: SUSPICIOUS_TOOL_CALL_SENTINEL_ID,
22109
+ description: "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history.",
22110
+ factory: () => new SuspiciousToolCallDetector()
22111
+ }
22112
+ ];
22113
+ var FILE_VERSION = 1;
22114
+ function sentinelSubscriptionsPath(storagePath) {
22115
+ return path.join(storagePath, "sentinel-subscriptions.json");
22116
+ }
22117
+ async function loadSentinelSubscriptions(storagePath) {
22118
+ const filePath = sentinelSubscriptionsPath(storagePath);
22119
+ try {
22120
+ const raw = await promises.readFile(filePath, "utf8");
22121
+ const parsed = JSON.parse(raw);
22122
+ if (parsed.version !== FILE_VERSION) return /* @__PURE__ */ new Set();
22123
+ if (!Array.isArray(parsed.subscribed)) return /* @__PURE__ */ new Set();
22124
+ const cleaned = parsed.subscribed.filter(
22125
+ (id) => typeof id === "string" && id.length > 0
22126
+ );
22127
+ return new Set(cleaned);
22128
+ } catch {
22129
+ return /* @__PURE__ */ new Set();
22130
+ }
20330
22131
  }
20331
22132
 
20332
22133
  // src/principal-policy/tools.ts
@@ -32503,7 +34304,17 @@ var OPERATOR_CHAT_OPS = {
32503
34304
  * fold. The concierge omits that category and continues; the user-
32504
34305
  * facing query is never broken. Body carries category + failure_reason.
32505
34306
  */
32506
- CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed"
34307
+ CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed",
34308
+ /**
34309
+ * Concierge surfaced a proactive starter when a fresh conversation
34310
+ * thread opened (WP-V1.3-9 Tau-5). Emitted once per starter, never on
34311
+ * follow-up turns within the same thread. Body carries `thread_id`,
34312
+ * `trigger` (stable enum), and `triggered_agents_count`. The starter
34313
+ * text body is NOT carried; the trigger enum is sufficient for
34314
+ * dashboard grouping and keeps fortress-internal agent ids off the
34315
+ * audit surface.
34316
+ */
34317
+ CONCIERGE_PROACTIVE_SUGGESTION_OFFERED: "operator_concierge_proactive_suggestion_offered"
32507
34318
  };
32508
34319
 
32509
34320
  // src/chat/operator-chat-types.ts
@@ -33313,6 +35124,100 @@ function auditSafeSummary(parsed) {
33313
35124
  };
33314
35125
  }
33315
35126
 
35127
+ // src/chat/agent-context-cache.ts
35128
+ var STATE_FLAG_ORDER = [
35129
+ "stuck",
35130
+ "has_pending_approvals",
35131
+ "has_open_findings",
35132
+ "active",
35133
+ "idle"
35134
+ ];
35135
+ var SECTION_HEADER = "## Current agent state";
35136
+ function approxTokenLen2(text) {
35137
+ return Math.ceil(text.length / 4);
35138
+ }
35139
+ var DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET = 400;
35140
+ function formatSnapshotLine(snapshot) {
35141
+ const flagLabel = snapshot.state_flags.join("+") || "no_flags";
35142
+ const work = snapshot.current_work_summary ? `, last: ${snapshot.current_work_summary}` : "";
35143
+ const verascore = snapshot.recent_verascore_delta_24h !== null ? `, verascore \u0394${snapshot.recent_verascore_delta_24h.toFixed(2)}` : "";
35144
+ return `- ${snapshot.agent_name} (template: ${snapshot.template}): ${flagLabel}, ${snapshot.recent_audit_count_24h} audit/24h, ${snapshot.recent_concordia_receipts_count_24h} receipts${verascore}${work}`;
35145
+ }
35146
+ function urgencyRank(snapshot) {
35147
+ for (let i = 0; i < STATE_FLAG_ORDER.length; i++) {
35148
+ if (snapshot.state_flags.includes(STATE_FLAG_ORDER[i])) {
35149
+ return i;
35150
+ }
35151
+ }
35152
+ return STATE_FLAG_ORDER.length;
35153
+ }
35154
+ function formatCurrentAgentStateSection(snapshots, opts) {
35155
+ if (snapshots.length === 0) return "";
35156
+ const budget = opts?.maxTokens ?? DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
35157
+ const sorted = [...snapshots].sort(
35158
+ (a, b) => urgencyRank(a) - urgencyRank(b)
35159
+ );
35160
+ const headerTokens = approxTokenLen2(`${SECTION_HEADER}
35161
+ `);
35162
+ const sepTokens = approxTokenLen2("\n");
35163
+ let runningTokens = headerTokens;
35164
+ const kept = [];
35165
+ for (const snap of sorted) {
35166
+ const line = formatSnapshotLine(snap);
35167
+ const tokens = approxTokenLen2(line) + (kept.length > 0 ? sepTokens : 0);
35168
+ if (kept.length === 0) {
35169
+ kept.push(line);
35170
+ runningTokens += tokens;
35171
+ continue;
35172
+ }
35173
+ if (runningTokens + tokens > budget) break;
35174
+ kept.push(line);
35175
+ runningTokens += tokens;
35176
+ }
35177
+ return `${SECTION_HEADER}
35178
+ ${kept.join("\n")}`;
35179
+ }
35180
+ function generateProactiveStarter(snapshots) {
35181
+ if (snapshots.length === 0) return null;
35182
+ const stuck = snapshots.filter((s) => s.state_flags.includes("stuck"));
35183
+ if (stuck.length > 0) {
35184
+ const first = stuck[0];
35185
+ if (first === void 0) return null;
35186
+ const last = first.current_work_summary ? ` (last: ${first.current_work_summary})` : "";
35187
+ return {
35188
+ text: `Your ${first.agent_name} agent looks stuck${last}. Should I check its session state?`,
35189
+ trigger: "stuck_agent",
35190
+ triggered_agents_count: stuck.length
35191
+ };
35192
+ }
35193
+ const pending = snapshots.filter(
35194
+ (s) => s.state_flags.includes("has_pending_approvals")
35195
+ );
35196
+ if (pending.length > 0) {
35197
+ const names = pending.slice(0, 3).map((s) => s.agent_name).join(", ");
35198
+ return {
35199
+ text: `You have pending approvals across ${names}. Want to walk through them?`,
35200
+ trigger: "pending_approvals",
35201
+ triggered_agents_count: pending.length
35202
+ };
35203
+ }
35204
+ const findings = snapshots.filter(
35205
+ (s) => s.state_flags.includes("has_open_findings")
35206
+ );
35207
+ if (findings.length > 0) {
35208
+ return {
35209
+ text: `Sentinel has open findings on ${findings.length} ${findings.length === 1 ? "agent" : "agents"}. Want a summary?`,
35210
+ trigger: "open_findings",
35211
+ triggered_agents_count: findings.length
35212
+ };
35213
+ }
35214
+ return {
35215
+ text: "Your fortress is quiet. Anything you'd like to inspect?",
35216
+ trigger: "all_idle",
35217
+ triggered_agents_count: snapshots.length
35218
+ };
35219
+ }
35220
+
33316
35221
  // src/chat/operator-chat-service.ts
33317
35222
  var DEFAULT_CONCIERGE_MAX_TOKENS = 512;
33318
35223
  var DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
@@ -33320,7 +35225,8 @@ var DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
33320
35225
  var DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
33321
35226
  var DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
33322
35227
  var DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET = 2e3;
33323
- function approxTokenLen2(text) {
35228
+ var DEFAULT_CONCIERGE_AGENT_STATE_BUDGET = 400;
35229
+ function approxTokenLen3(text) {
33324
35230
  return Math.ceil(text.length / 4);
33325
35231
  }
33326
35232
  var SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
@@ -33369,6 +35275,15 @@ var OperatorChatService = class {
33369
35275
  dynamicContextBudget;
33370
35276
  agentRegistry;
33371
35277
  grammarLlmAssist;
35278
+ agentContextCache;
35279
+ agentStateBudget;
35280
+ /**
35281
+ * Per-thread guard so the proactive starter fires at most once per
35282
+ * fresh thread. Tracks the thread_id the starter was last offered
35283
+ * for; subsequent `getProactiveStarter()` calls within the same
35284
+ * thread return null instead of re-emitting.
35285
+ */
35286
+ starterOfferedForThreadId;
33372
35287
  /**
33373
35288
  * In-memory thread_id assigned to the active concierge session.
33374
35289
  * The first sendConcierge call after construction allocates a fresh
@@ -33413,6 +35328,10 @@ var OperatorChatService = class {
33413
35328
  if (deps.conciergeGrammarLlmAssist) {
33414
35329
  this.grammarLlmAssist = deps.conciergeGrammarLlmAssist;
33415
35330
  }
35331
+ if (deps.conciergeAgentContextCache) {
35332
+ this.agentContextCache = deps.conciergeAgentContextCache;
35333
+ }
35334
+ this.agentStateBudget = deps.conciergeAgentStateBudget !== void 0 && deps.conciergeAgentStateBudget > 0 ? deps.conciergeAgentStateBudget : DEFAULT_CONCIERGE_AGENT_STATE_BUDGET;
33416
35335
  }
33417
35336
  // ── Concierge ─────────────────────────────────────────────────────────
33418
35337
  /**
@@ -33434,6 +35353,7 @@ var OperatorChatService = class {
33434
35353
  const nowMs = this.clock();
33435
35354
  if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
33436
35355
  this.activeMemoryThreadId = void 0;
35356
+ this.starterOfferedForThreadId = void 0;
33437
35357
  }
33438
35358
  const operatorMessage = {
33439
35359
  message_id: crypto.randomUUID(),
@@ -33472,6 +35392,11 @@ var OperatorChatService = class {
33472
35392
  });
33473
35393
  }
33474
35394
  const parsedGrammar = await this.runGrammarParse(filterResult.filtered);
35395
+ const agentSnapshots = this.agentContextCache ? this.agentContextCache.read() : [];
35396
+ const agentStateSection = this.agentContextCache ? formatCurrentAgentStateSection(agentSnapshots, {
35397
+ maxTokens: this.agentStateBudget
35398
+ }) : "";
35399
+ const renderedAgentCount = agentStateSection ? agentSnapshots.length : 0;
33475
35400
  const start = Date.now();
33476
35401
  let conciergeBody;
33477
35402
  let servedBy = "disabled";
@@ -33496,7 +35421,8 @@ var OperatorChatService = class {
33496
35421
  dynamicCategoriesIncluded = dynamicResult.categoriesIncluded;
33497
35422
  const context = await this.assembleConciergeContext(
33498
35423
  priorTurns,
33499
- dynamicResult.section
35424
+ dynamicResult.section,
35425
+ agentStateSection
33500
35426
  );
33501
35427
  const response = await this.substrateSelector.invokeSummarize(
33502
35428
  "concierge",
@@ -33563,7 +35489,8 @@ var OperatorChatService = class {
33563
35489
  prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
33564
35490
  } : {},
33565
35491
  ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {},
33566
- parsed_grammar: auditSafeSummary(parsedGrammar)
35492
+ parsed_grammar: auditSafeSummary(parsedGrammar),
35493
+ ...this.agentContextCache !== void 0 ? { agent_context_snapshot_count: renderedAgentCount } : {}
33567
35494
  };
33568
35495
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
33569
35496
  return {
@@ -33674,6 +35601,7 @@ var OperatorChatService = class {
33674
35601
  if (!removed) return false;
33675
35602
  if (this.activeMemoryThreadId === threadId) {
33676
35603
  this.activeMemoryThreadId = void 0;
35604
+ this.starterOfferedForThreadId = void 0;
33677
35605
  }
33678
35606
  const payload = {
33679
35607
  version: "1.2",
@@ -33692,9 +35620,65 @@ var OperatorChatService = class {
33692
35620
  * Reset the active session memory thread. Subsequent sendConcierge
33693
35621
  * calls allocate a fresh thread_id. Surfaced for tests + future "new
33694
35622
  * conversation" affordance; not currently called by the dashboard.
35623
+ *
35624
+ * Tau-5: also clears the proactive-starter guard so the next
35625
+ * `getProactiveStarter()` call against the freshly-allocated thread
35626
+ * is eligible to fire.
33695
35627
  */
33696
35628
  resetConciergeMemoryThread() {
33697
35629
  this.activeMemoryThreadId = void 0;
35630
+ this.starterOfferedForThreadId = void 0;
35631
+ }
35632
+ /**
35633
+ * WP-V1.3-9 Tau-5: surface a proactive starter for the current
35634
+ * concierge session. Intended to be called by the dashboard UI when
35635
+ * the operator opens the chat surface, before any operator typing.
35636
+ *
35637
+ * Returns null when:
35638
+ * - No agent-context cache is wired (Tau-5 disabled).
35639
+ * - No concierge memory store is wired (no thread_id namespace).
35640
+ * - The cache snapshot has no signal (empty fortress).
35641
+ * - A starter has already been offered for the active thread (the
35642
+ * guard ensures one starter per fresh thread).
35643
+ *
35644
+ * Side effects:
35645
+ * - Allocates a fresh thread_id if none is active.
35646
+ * - Emits the `operator_concierge_proactive_suggestion_offered`
35647
+ * audit event with the trigger class + triggered_agents_count.
35648
+ * - Records the offered thread_id so the next call within the same
35649
+ * thread is a no-op.
35650
+ *
35651
+ * The returned starter's `text` is operator-visible copy; the
35652
+ * dashboard renders it as a system-message-style starter the
35653
+ * operator can accept (clicks/types follow-up) or dismiss (types a
35654
+ * new query).
35655
+ */
35656
+ getProactiveStarter() {
35657
+ if (!this.agentContextCache) return null;
35658
+ if (!this.memory) return null;
35659
+ const threadId = this.ensureActiveMemoryThread();
35660
+ if (this.starterOfferedForThreadId === threadId) return null;
35661
+ const snapshots = this.agentContextCache.read();
35662
+ const starter = generateProactiveStarter(snapshots);
35663
+ if (!starter) return null;
35664
+ const payload = {
35665
+ version: "1.2",
35666
+ event_id: makeEventId("conc-starter"),
35667
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
35668
+ identity_id: this.identityId,
35669
+ kind: "operator_concierge_proactive_suggestion_offered",
35670
+ surface: "concierge",
35671
+ thread_id: threadId,
35672
+ trigger: starter.trigger,
35673
+ triggered_agents_count: starter.triggered_agents_count
35674
+ };
35675
+ this.emit(
35676
+ OPERATOR_CHAT_OPS.CONCIERGE_PROACTIVE_SUGGESTION_OFFERED,
35677
+ payload,
35678
+ "success"
35679
+ );
35680
+ this.starterOfferedForThreadId = threadId;
35681
+ return starter;
33698
35682
  }
33699
35683
  ensureActiveMemoryThread() {
33700
35684
  if (!this.activeMemoryThreadId) {
@@ -33739,7 +35723,7 @@ var OperatorChatService = class {
33739
35723
  * if available; the v1.2 selector does not expose one, so structured
33740
35724
  * serialization is the canonical path for v1.3.
33741
35725
  */
33742
- async assembleConciergeContext(priorTurns = [], dynamicSection = "") {
35726
+ async assembleConciergeContext(priorTurns = [], dynamicSection = "", agentStateSection = "") {
33743
35727
  const ref = `## Sanctuary reference
33744
35728
  ${SANCTUARY_DOMAIN_REFERENCE}`;
33745
35729
  const priorSection = this.formatPriorTurnsSection(priorTurns);
@@ -33747,6 +35731,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
33747
35731
  return [
33748
35732
  ref,
33749
35733
  ...dynamicSection ? [dynamicSection] : [],
35734
+ ...agentStateSection ? [agentStateSection] : [],
33750
35735
  ...priorSection ? [priorSection] : [],
33751
35736
  "## Recent activity\n(no providers wired)",
33752
35737
  "## Wrapped agents\n(no providers wired)",
@@ -33761,6 +35746,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
33761
35746
  return [
33762
35747
  ref,
33763
35748
  ...dynamicSection ? [dynamicSection] : [],
35749
+ ...agentStateSection ? [agentStateSection] : [],
33764
35750
  ...priorSection ? [priorSection] : [],
33765
35751
  `## Recent activity
33766
35752
  ${activity}`,
@@ -33843,14 +35829,14 @@ ${inbox}`
33843
35829
  if (turns.length === 0) return "";
33844
35830
  const HEADER = "## Prior conversation";
33845
35831
  const lines = turns.map(formatPriorTurnLine);
33846
- const headerTokens = approxTokenLen2(`${HEADER}
35832
+ const headerTokens = approxTokenLen3(`${HEADER}
33847
35833
  `);
33848
- const sepTokens = approxTokenLen2("\n");
35834
+ const sepTokens = approxTokenLen3("\n");
33849
35835
  let runningTokens = headerTokens;
33850
35836
  let runningLines = [];
33851
35837
  for (let i = lines.length - 1; i >= 0; i--) {
33852
35838
  const line = lines[i];
33853
- const tokens = approxTokenLen2(line) + (runningLines.length > 0 ? sepTokens : 0);
35839
+ const tokens = approxTokenLen3(line) + (runningLines.length > 0 ? sepTokens : 0);
33854
35840
  if (runningTokens + tokens > this.historyTokenBudget) break;
33855
35841
  runningTokens += tokens;
33856
35842
  runningLines.push(line);
@@ -33897,7 +35883,7 @@ function hashOf(input) {
33897
35883
  init_encryption();
33898
35884
  init_encoding();
33899
35885
  var OPERATOR_CHAT_NAMESPACE = "_chat";
33900
- var HKDF_INFO2 = "operator-chat-store-v1";
35886
+ var HKDF_INFO3 = "operator-chat-store-v1";
33901
35887
  function chatStorageKey(surface, threadKey) {
33902
35888
  return `${surface}.${threadKey}`;
33903
35889
  }
@@ -33906,7 +35892,7 @@ var OperatorChatStore = class {
33906
35892
  encryptionKey;
33907
35893
  constructor(storage, masterKey) {
33908
35894
  this.storage = storage;
33909
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
35895
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
33910
35896
  }
33911
35897
  /**
33912
35898
  * Load a thread. Returns null if no record exists or if the on-disk
@@ -33990,7 +35976,7 @@ init_encryption();
33990
35976
  init_encoding();
33991
35977
  var CONCIERGE_MEMORY_NAMESPACE = "_chat";
33992
35978
  var CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
33993
- var HKDF_INFO3 = "concierge-memory-store-v1";
35979
+ var HKDF_INFO4 = "concierge-memory-store-v1";
33994
35980
  var DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
33995
35981
  var MAX_BUNDLE_BYTES3 = 4 * 1024 * 1024;
33996
35982
  var ConciergeMemoryStore = class {
@@ -34001,7 +35987,7 @@ var ConciergeMemoryStore = class {
34001
35987
  locks;
34002
35988
  constructor(opts) {
34003
35989
  this.storage = opts.storage;
34004
- this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO3);
35990
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO4);
34005
35991
  this.fortressId = opts.fortressId;
34006
35992
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
34007
35993
  this.locks = /* @__PURE__ */ new Map();
@@ -34127,7 +36113,7 @@ var ConciergeMemoryStore = class {
34127
36113
  );
34128
36114
  const summaries = [];
34129
36115
  for (const meta of entries) {
34130
- const threadId = stripKeyPrefix2(meta.key);
36116
+ const threadId = stripKeyPrefix3(meta.key);
34131
36117
  if (threadId === null) continue;
34132
36118
  const bundle = await this.loadBundle(threadId);
34133
36119
  if (!bundle || bundle.turns.length === 0) continue;
@@ -34180,7 +36166,7 @@ var ConciergeMemoryStore = class {
34180
36166
  );
34181
36167
  let pruned = 0;
34182
36168
  for (const meta of entries) {
34183
- const threadId = stripKeyPrefix2(meta.key);
36169
+ const threadId = stripKeyPrefix3(meta.key);
34184
36170
  if (threadId === null) continue;
34185
36171
  pruned += await this.withLock(threadId, async () => {
34186
36172
  const bundle = await this.loadBundle(threadId);
@@ -34264,7 +36250,7 @@ var ConciergeMemoryStore = class {
34264
36250
  function bundleKey(threadId) {
34265
36251
  return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
34266
36252
  }
34267
- function stripKeyPrefix2(key) {
36253
+ function stripKeyPrefix3(key) {
34268
36254
  if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
34269
36255
  return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
34270
36256
  }
@@ -34637,13 +36623,13 @@ init_encryption();
34637
36623
  init_encoding();
34638
36624
  var INTELLIGENCE_NAMESPACE = "_intelligence";
34639
36625
  var SUBSTRATE_CONFIG_KEY = "substrate-config";
34640
- var HKDF_INFO4 = "intelligence-substrate-config";
36626
+ var HKDF_INFO5 = "intelligence-substrate-config";
34641
36627
  var IntelligenceConfigStore = class {
34642
36628
  storage;
34643
36629
  encryptionKey;
34644
36630
  constructor(storage, masterKey) {
34645
36631
  this.storage = storage;
34646
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO4);
36632
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO5);
34647
36633
  }
34648
36634
  /**
34649
36635
  * Load the operator's substrate config from disk. Returns the config
@@ -38622,6 +40608,38 @@ ${err.message}
38622
40608
  if (dashboard) {
38623
40609
  dashboard.setApprovalAggregator(approvalAggregator);
38624
40610
  }
40611
+ const sentinelFindingStore = new SentinelFindingStore({
40612
+ storage,
40613
+ masterKey,
40614
+ fortressId: fortressIdForAggregator
40615
+ });
40616
+ const sentinelRegistry = new SentinelRegistry();
40617
+ for (const entry of PHI1_BASELINE_CATALOG) {
40618
+ sentinelRegistry.register(entry);
40619
+ }
40620
+ const sentinelDispatcher = new SentinelDispatcher({
40621
+ registry: sentinelRegistry,
40622
+ findingStore: sentinelFindingStore,
40623
+ auditLog,
40624
+ fortressId: fortressIdForAggregator,
40625
+ identityId: aggregatorIdentityId
40626
+ });
40627
+ try {
40628
+ const persistedSubscriptions = await loadSentinelSubscriptions(
40629
+ config.storage_path
40630
+ );
40631
+ for (const sentinelId of persistedSubscriptions) {
40632
+ try {
40633
+ await sentinelDispatcher.subscribeSentinel(sentinelId);
40634
+ } catch {
40635
+ }
40636
+ }
40637
+ } catch {
40638
+ }
40639
+ sentinelDispatcher.start();
40640
+ if (dashboard) {
40641
+ dashboard.setSentinelDispatcher(sentinelDispatcher);
40642
+ }
38625
40643
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
38626
40644
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
38627
40645
  config,