@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/cli.js CHANGED
@@ -17354,6 +17354,125 @@ var init_approval_aggregator_routes = __esm({
17354
17354
  APPROVAL_INBOX_MAX_LIMIT = 200;
17355
17355
  }
17356
17356
  });
17357
+
17358
+ // src/sentinel/sentinel-routes.ts
17359
+ function writeJSON5(res, status, payload) {
17360
+ res.writeHead(status, {
17361
+ "Content-Type": "application/json",
17362
+ "Cache-Control": "no-store"
17363
+ });
17364
+ res.end(JSON.stringify(payload));
17365
+ }
17366
+ function isSeverity(value) {
17367
+ return value === "info" || value === "warn" || value === "alert";
17368
+ }
17369
+ function parseLimit3(raw, defaultValue, max) {
17370
+ if (raw === null || raw === "") return defaultValue;
17371
+ const parsed = Number.parseInt(raw, 10);
17372
+ if (Number.isNaN(parsed) || parsed < 0) return defaultValue;
17373
+ return Math.min(parsed, max);
17374
+ }
17375
+ function matchSubscribeRoute(path) {
17376
+ const prefix = `${SENTINEL_API_PREFIX}/`;
17377
+ if (!path.startsWith(prefix)) return null;
17378
+ const rest = path.slice(prefix.length);
17379
+ if (!rest.endsWith("/subscribe")) return null;
17380
+ const sentinelId = rest.slice(0, rest.length - "/subscribe".length);
17381
+ if (sentinelId.length === 0) return null;
17382
+ return { sentinelId: decodeURIComponent(sentinelId) };
17383
+ }
17384
+ async function handleSentinelRoute(deps, req, res) {
17385
+ const host = req.headers.host || "localhost";
17386
+ const url = new URL(req.url ?? "/", `http://${host}`);
17387
+ const method = (req.method ?? "GET").toUpperCase();
17388
+ const path = url.pathname;
17389
+ if (path !== SENTINEL_API_PREFIX && !path.startsWith(`${SENTINEL_API_PREFIX}/`)) {
17390
+ return false;
17391
+ }
17392
+ const checkAuth = authMiddleware(deps.authConfig);
17393
+ if (!checkAuth(req, res, url)) return true;
17394
+ const dispatcher = deps.dispatcher;
17395
+ const registry = dispatcher.getRegistry();
17396
+ const findingStore = dispatcher.getFindingStore();
17397
+ try {
17398
+ if (method === "GET" && path === SENTINEL_API_PREFIX) {
17399
+ const catalog = registry.listCatalog();
17400
+ writeJSON5(res, 200, { ok: true, data: { catalog } });
17401
+ return true;
17402
+ }
17403
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/subscribed`) {
17404
+ const subscribed = registry.listSubscribed();
17405
+ writeJSON5(res, 200, { ok: true, data: { subscribed } });
17406
+ return true;
17407
+ }
17408
+ if (method === "GET" && path === `${SENTINEL_API_PREFIX}/findings`) {
17409
+ const limit = parseLimit3(
17410
+ url.searchParams.get("limit"),
17411
+ FINDINGS_DEFAULT_LIMIT,
17412
+ FINDINGS_MAX_LIMIT
17413
+ );
17414
+ const since = url.searchParams.get("since") ?? void 0;
17415
+ const severityRaw = url.searchParams.get("severity") ?? void 0;
17416
+ const sentinelIdFilter = url.searchParams.get("sentinel_id") ?? void 0;
17417
+ const agentIdFilter = url.searchParams.get("agent_id") ?? void 0;
17418
+ const severity = severityRaw && isSeverity(severityRaw) ? severityRaw : void 0;
17419
+ const findings = await findingStore.listFindings({
17420
+ limit,
17421
+ ...since !== void 0 ? { since } : {},
17422
+ ...severity !== void 0 ? { severity } : {},
17423
+ ...sentinelIdFilter !== void 0 ? { sentinelId: sentinelIdFilter } : {},
17424
+ ...agentIdFilter !== void 0 ? { agentId: agentIdFilter } : {}
17425
+ });
17426
+ writeJSON5(res, 200, { ok: true, data: { findings } });
17427
+ return true;
17428
+ }
17429
+ const subscribeMatch = matchSubscribeRoute(path);
17430
+ if (subscribeMatch) {
17431
+ if (method === "POST") {
17432
+ try {
17433
+ await dispatcher.subscribeSentinel(subscribeMatch.sentinelId);
17434
+ writeJSON5(res, 200, {
17435
+ ok: true,
17436
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: true }
17437
+ });
17438
+ } catch (err) {
17439
+ const msg = err instanceof Error ? err.message : String(err);
17440
+ if (msg.startsWith("sentinel-registry: unknown sentinel")) {
17441
+ writeJSON5(res, 404, { ok: false, error: "not_found" });
17442
+ } else {
17443
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
17444
+ }
17445
+ }
17446
+ return true;
17447
+ }
17448
+ if (method === "DELETE") {
17449
+ const removed = await dispatcher.unsubscribeSentinel(
17450
+ subscribeMatch.sentinelId
17451
+ );
17452
+ writeJSON5(res, 200, {
17453
+ ok: true,
17454
+ data: { sentinel_id: subscribeMatch.sentinelId, subscribed: false, removed }
17455
+ });
17456
+ return true;
17457
+ }
17458
+ }
17459
+ writeJSON5(res, 404, { ok: false, error: "not_found", path });
17460
+ return true;
17461
+ } catch (err) {
17462
+ const msg = err instanceof Error ? err.message : String(err);
17463
+ writeJSON5(res, 500, { ok: false, error: "internal", detail: msg });
17464
+ return true;
17465
+ }
17466
+ }
17467
+ var SENTINEL_API_PREFIX, FINDINGS_DEFAULT_LIMIT, FINDINGS_MAX_LIMIT;
17468
+ var init_sentinel_routes = __esm({
17469
+ "src/sentinel/sentinel-routes.ts"() {
17470
+ init_auth_middleware();
17471
+ SENTINEL_API_PREFIX = "/api/sentinels";
17472
+ FINDINGS_DEFAULT_LIMIT = 100;
17473
+ FINDINGS_MAX_LIMIT = 500;
17474
+ }
17475
+ });
17357
17476
  function isDashboardViewRoute(method, path) {
17358
17477
  if (method !== "GET") return false;
17359
17478
  return path === "/" || path === "/dashboard" || path === "/v1.0" || path === "/fortress" || path === "/events";
@@ -17368,6 +17487,7 @@ var init_dashboard = __esm({
17368
17487
  init_system_prompt_generator();
17369
17488
  init_dispatch();
17370
17489
  init_approval_aggregator_routes();
17490
+ init_sentinel_routes();
17371
17491
  SESSION_TTL_REMOTE_MS = 5 * 60 * 1e3;
17372
17492
  SESSION_TTL_LOCAL_MS = 24 * 60 * 60 * 1e3;
17373
17493
  MAX_SESSIONS = 1e3;
@@ -17435,6 +17555,13 @@ var init_dashboard = __esm({
17435
17555
  * the operator-facing query / decision surface.
17436
17556
  */
17437
17557
  approvalAggregator = null;
17558
+ /**
17559
+ * v1.3 WP-V1.3-1 Phi-1 Sentinel dispatcher. Mounted additively at
17560
+ * `/api/sentinels/*` when set. Sentinel surface is read-only against
17561
+ * the audit log; subscribe/unsubscribe writes flow through the
17562
+ * dispatcher's audited paths.
17563
+ */
17564
+ sentinelDispatcher = null;
17438
17565
  constructor(config) {
17439
17566
  this.config = config;
17440
17567
  this.authToken = config.auth_token;
@@ -17494,6 +17621,14 @@ var init_dashboard = __esm({
17494
17621
  setApprovalAggregator(aggregator) {
17495
17622
  this.approvalAggregator = aggregator;
17496
17623
  }
17624
+ /**
17625
+ * v1.3 WP-V1.3-1 Phi-1: bind the Sentinel dispatcher. Once set,
17626
+ * requests to `/api/sentinels/*` route through `handleSentinelRoute`.
17627
+ * Pass `null` to detach (used by tests + during shutdown).
17628
+ */
17629
+ setSentinelDispatcher(dispatcher) {
17630
+ this.sentinelDispatcher = dispatcher;
17631
+ }
17497
17632
  /**
17498
17633
  * v1.3 WP-V1.3-10 dispatch entry point. Called from `handleRequest`
17499
17634
  * before the legacy approval route table. Returns true when served.
@@ -17513,6 +17648,25 @@ var init_dashboard = __esm({
17513
17648
  res
17514
17649
  );
17515
17650
  }
17651
+ /**
17652
+ * v1.3 WP-V1.3-1 Phi-1 dispatch entry point. Routes `/api/sentinels/*`
17653
+ * requests through the sentinel router when a dispatcher has been
17654
+ * bound. Returns true when served.
17655
+ */
17656
+ async dispatchSentinel(req, res) {
17657
+ if (!this.sentinelDispatcher) return false;
17658
+ return handleSentinelRoute(
17659
+ {
17660
+ authConfig: {
17661
+ loopbackAutoAuth: this._autoAuthLocalhost,
17662
+ ...this.authToken !== void 0 ? { authToken: this.authToken } : {}
17663
+ },
17664
+ dispatcher: this.sentinelDispatcher
17665
+ },
17666
+ req,
17667
+ res
17668
+ );
17669
+ }
17516
17670
  /**
17517
17671
  * v1.1 dispatch entry point. Called from `handleRequest` before the
17518
17672
  * legacy route table. Returns true when the request was served by v1.1
@@ -17900,6 +18054,18 @@ var init_dashboard = __esm({
17900
18054
  });
17901
18055
  return;
17902
18056
  }
18057
+ if (this.sentinelDispatcher && url.pathname.startsWith(SENTINEL_API_PREFIX)) {
18058
+ this.dispatchSentinel(req, res).then((handled) => {
18059
+ if (handled) return;
18060
+ this.handleLegacyRequest(req, res, url, method);
18061
+ }).catch(() => {
18062
+ if (!res.headersSent) {
18063
+ res.writeHead(500, { "Content-Type": "application/json" });
18064
+ res.end(JSON.stringify({ error: "Internal server error" }));
18065
+ }
18066
+ });
18067
+ return;
18068
+ }
17903
18069
  if (this.v11Bindings) {
17904
18070
  this.dispatchV11(req, res, url, method).then((handled) => {
17905
18071
  if (handled) return;
@@ -21240,47 +21406,1766 @@ var init_aggregator_store = __esm({
21240
21406
  } catch {
21241
21407
  return false;
21242
21408
  }
21243
- return true;
21409
+ return true;
21410
+ }
21411
+ /**
21412
+ * Drop expired payload bundles. Returns the count of bundles pruned.
21413
+ * Caller wires this into the cocoon-unlock initialization path.
21414
+ */
21415
+ async pruneExpired(now) {
21416
+ const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
21417
+ const entries = await this.storage.list(
21418
+ AGGREGATOR_PAYLOAD_NAMESPACE,
21419
+ AGGREGATOR_PAYLOAD_KEY_PREFIX
21420
+ );
21421
+ let pruned = 0;
21422
+ for (const meta of entries) {
21423
+ const aggregatorId = stripKeyPrefix(meta.key);
21424
+ if (aggregatorId === null) continue;
21425
+ const raw = await this.storage.read(
21426
+ AGGREGATOR_PAYLOAD_NAMESPACE,
21427
+ meta.key
21428
+ );
21429
+ if (!raw) continue;
21430
+ try {
21431
+ const envelope = JSON.parse(bytesToString(raw));
21432
+ const aad = stringToBytes(aggregatorId);
21433
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21434
+ const parsed = JSON.parse(
21435
+ bytesToString(plaintext)
21436
+ );
21437
+ if (parsed.retention_until <= cutoff) {
21438
+ await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, meta.key);
21439
+ pruned += 1;
21440
+ }
21441
+ } catch {
21442
+ }
21443
+ }
21444
+ return { pruned };
21445
+ }
21446
+ };
21447
+ }
21448
+ });
21449
+
21450
+ // src/sentinel/types.ts
21451
+ function isProxyCallAuditEntry(entry) {
21452
+ return entry.operation.startsWith(
21453
+ SENTINEL_OBSERVED_AUDIT_OPS.PROXY_CALL_PREFIX
21454
+ );
21455
+ }
21456
+ function proxyServerFromAuditEntry(entry) {
21457
+ if (!isProxyCallAuditEntry(entry)) return null;
21458
+ const details = entry.details;
21459
+ if (!details) return null;
21460
+ const server = details["server"];
21461
+ if (typeof server !== "string" || server.length === 0) return null;
21462
+ return server;
21463
+ }
21464
+ var SENTINEL_SUMMARY_MAX_CHARS, SENTINEL_AUDIT_OPS, SENTINEL_OBSERVED_AUDIT_OPS;
21465
+ var init_types3 = __esm({
21466
+ "src/sentinel/types.ts"() {
21467
+ SENTINEL_SUMMARY_MAX_CHARS = 240;
21468
+ SENTINEL_AUDIT_OPS = {
21469
+ SUBSCRIBED: "sentinel_subscribed",
21470
+ UNSUBSCRIBED: "sentinel_unsubscribed",
21471
+ FINDING_EMITTED: "sentinel_finding_emitted",
21472
+ EVALUATION_FAILED: "sentinel_evaluation_failed"
21473
+ };
21474
+ SENTINEL_OBSERVED_AUDIT_OPS = {
21475
+ /** Proxy router emits this on every outbound call (success or failure). */
21476
+ PROXY_CALL_PREFIX: "proxy_call:"
21477
+ };
21478
+ }
21479
+ });
21480
+
21481
+ // src/sentinel/sentinel-finding-store.ts
21482
+ function findingKey(findingId) {
21483
+ return `${SENTINEL_FINDING_KEY_PREFIX}${findingId}`;
21484
+ }
21485
+ function stripKeyPrefix2(key) {
21486
+ if (!key.startsWith(SENTINEL_FINDING_KEY_PREFIX)) return null;
21487
+ return key.slice(SENTINEL_FINDING_KEY_PREFIX.length);
21488
+ }
21489
+ function truncateSummary(summary) {
21490
+ if (summary.length <= SENTINEL_SUMMARY_MAX_CHARS) return summary;
21491
+ return `${summary.slice(0, SENTINEL_SUMMARY_MAX_CHARS - 3)}...`;
21492
+ }
21493
+ var SENTINEL_FINDING_NAMESPACE, SENTINEL_FINDING_KEY_PREFIX, HKDF_INFO2, DEFAULT_SENTINEL_FINDING_RETENTION_DAYS, MAX_FINDING_BYTES, SentinelFindingStore;
21494
+ var init_sentinel_finding_store = __esm({
21495
+ "src/sentinel/sentinel-finding-store.ts"() {
21496
+ init_encryption();
21497
+ init_key_derivation();
21498
+ init_encoding();
21499
+ init_types3();
21500
+ SENTINEL_FINDING_NAMESPACE = "_sentinel_findings";
21501
+ SENTINEL_FINDING_KEY_PREFIX = "finding.";
21502
+ HKDF_INFO2 = "l2-sentinel-finding-v1";
21503
+ DEFAULT_SENTINEL_FINDING_RETENTION_DAYS = 30;
21504
+ MAX_FINDING_BYTES = 256 * 1024;
21505
+ SentinelFindingStore = class {
21506
+ storage;
21507
+ encryptionKey;
21508
+ fortressId;
21509
+ retentionDays;
21510
+ now;
21511
+ constructor(opts) {
21512
+ this.storage = opts.storage;
21513
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO2);
21514
+ this.fortressId = opts.fortressId;
21515
+ this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_SENTINEL_FINDING_RETENTION_DAYS;
21516
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
21517
+ }
21518
+ /**
21519
+ * Persist a finding. Truncates the operator-visible summary to
21520
+ * SENTINEL_SUMMARY_MAX_CHARS so the dashboard render stays bounded.
21521
+ * Returns the retention deadline so callers can audit it.
21522
+ */
21523
+ async saveFinding(finding) {
21524
+ const truncated = {
21525
+ ...finding,
21526
+ fortress_id: this.fortressId,
21527
+ summary: truncateSummary(finding.summary)
21528
+ };
21529
+ const retentionMs = this.retentionDays * 24 * 60 * 60 * 1e3;
21530
+ const retentionUntil = new Date(this.now().getTime() + retentionMs);
21531
+ const persisted = {
21532
+ version: 1,
21533
+ finding: truncated,
21534
+ retention_until: retentionUntil.toISOString()
21535
+ };
21536
+ const aad = stringToBytes(finding.finding_id);
21537
+ const plaintext = stringToBytes(JSON.stringify(persisted));
21538
+ const envelope = encrypt(plaintext, this.encryptionKey, aad);
21539
+ await this.storage.write(
21540
+ SENTINEL_FINDING_NAMESPACE,
21541
+ findingKey(finding.finding_id),
21542
+ stringToBytes(JSON.stringify(envelope))
21543
+ );
21544
+ return persisted.retention_until;
21545
+ }
21546
+ /** Load a single finding by id, or null when absent / corrupted. */
21547
+ async loadFinding(findingId) {
21548
+ let raw;
21549
+ try {
21550
+ raw = await this.storage.read(
21551
+ SENTINEL_FINDING_NAMESPACE,
21552
+ findingKey(findingId)
21553
+ );
21554
+ } catch {
21555
+ return null;
21556
+ }
21557
+ if (!raw) return null;
21558
+ if (raw.length > MAX_FINDING_BYTES) return null;
21559
+ return this.decode(findingId, raw);
21560
+ }
21561
+ /**
21562
+ * List findings, newest first. Optional filters: since (ISO 8601),
21563
+ * severity, sentinel_id, agent_id, limit. Default limit 100.
21564
+ */
21565
+ async listFindings(opts) {
21566
+ const metas = await this.storage.list(
21567
+ SENTINEL_FINDING_NAMESPACE,
21568
+ SENTINEL_FINDING_KEY_PREFIX
21569
+ );
21570
+ const findings = [];
21571
+ for (const meta of metas) {
21572
+ const id = stripKeyPrefix2(meta.key);
21573
+ if (id === null) continue;
21574
+ const raw = await this.storage.read(
21575
+ SENTINEL_FINDING_NAMESPACE,
21576
+ meta.key
21577
+ );
21578
+ if (!raw) continue;
21579
+ if (raw.length > MAX_FINDING_BYTES) continue;
21580
+ const finding = await this.decode(id, raw);
21581
+ if (!finding) continue;
21582
+ if (opts?.since && finding.observed_at < opts.since) continue;
21583
+ if (opts?.severity && finding.severity !== opts.severity) continue;
21584
+ if (opts?.sentinelId && finding.sentinel_id !== opts.sentinelId) continue;
21585
+ if (opts?.agentId && finding.agent_id !== opts.agentId) continue;
21586
+ findings.push(finding);
21587
+ }
21588
+ findings.sort((a, b) => a.observed_at < b.observed_at ? 1 : -1);
21589
+ const limit = opts?.limit ?? 100;
21590
+ return findings.slice(0, limit);
21591
+ }
21592
+ /**
21593
+ * Drop expired findings. Returns the count removed.
21594
+ */
21595
+ async pruneExpired(now) {
21596
+ const cutoff = (now ?? this.now()).toISOString();
21597
+ const metas = await this.storage.list(
21598
+ SENTINEL_FINDING_NAMESPACE,
21599
+ SENTINEL_FINDING_KEY_PREFIX
21600
+ );
21601
+ let pruned = 0;
21602
+ for (const meta of metas) {
21603
+ const id = stripKeyPrefix2(meta.key);
21604
+ if (id === null) continue;
21605
+ const raw = await this.storage.read(
21606
+ SENTINEL_FINDING_NAMESPACE,
21607
+ meta.key
21608
+ );
21609
+ if (!raw) continue;
21610
+ try {
21611
+ const aad = stringToBytes(id);
21612
+ const envelope = JSON.parse(bytesToString(raw));
21613
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21614
+ const persisted = JSON.parse(
21615
+ bytesToString(plaintext)
21616
+ );
21617
+ if (persisted.retention_until <= cutoff) {
21618
+ await this.storage.delete(SENTINEL_FINDING_NAMESPACE, meta.key);
21619
+ pruned += 1;
21620
+ }
21621
+ } catch {
21622
+ }
21623
+ }
21624
+ return { pruned };
21625
+ }
21626
+ async decode(findingId, raw) {
21627
+ try {
21628
+ const aad = stringToBytes(findingId);
21629
+ const envelope = JSON.parse(bytesToString(raw));
21630
+ const plaintext = decrypt(envelope, this.encryptionKey, aad);
21631
+ const persisted = JSON.parse(
21632
+ bytesToString(plaintext)
21633
+ );
21634
+ if (persisted.version !== 1) return null;
21635
+ if (persisted.finding.finding_id !== findingId) return null;
21636
+ if (persisted.finding.fortress_id !== this.fortressId) return null;
21637
+ return persisted.finding;
21638
+ } catch {
21639
+ return null;
21640
+ }
21641
+ }
21642
+ };
21643
+ }
21644
+ });
21645
+
21646
+ // src/sentinel/sentinel-registry.ts
21647
+ var SentinelRegistry;
21648
+ var init_sentinel_registry = __esm({
21649
+ "src/sentinel/sentinel-registry.ts"() {
21650
+ SentinelRegistry = class {
21651
+ catalog = /* @__PURE__ */ new Map();
21652
+ subscribed = /* @__PURE__ */ new Map();
21653
+ register(entry) {
21654
+ if (this.catalog.has(entry.sentinelId)) {
21655
+ throw new Error(
21656
+ `sentinel-registry: ${entry.sentinelId} already registered`
21657
+ );
21658
+ }
21659
+ this.catalog.set(entry.sentinelId, entry);
21660
+ }
21661
+ /**
21662
+ * Available sentinels (catalog view). Operator UI lists this so the
21663
+ * operator can pick what to subscribe to.
21664
+ */
21665
+ listCatalog() {
21666
+ return [...this.catalog.values()].map((entry) => ({
21667
+ sentinelId: entry.sentinelId,
21668
+ description: entry.description
21669
+ }));
21670
+ }
21671
+ /** Currently subscribed sentinel ids. */
21672
+ listSubscribed() {
21673
+ return [...this.subscribed.keys()];
21674
+ }
21675
+ /** Has the fortress opted into this sentinel? */
21676
+ isSubscribed(sentinelId) {
21677
+ return this.subscribed.has(sentinelId);
21678
+ }
21679
+ /**
21680
+ * Subscribe a sentinel to a fortress context. Idempotent: a second
21681
+ * subscribe call on an already-subscribed sentinel returns the
21682
+ * existing instance without re-running `subscribe()`.
21683
+ */
21684
+ async subscribe(sentinelId, context) {
21685
+ const existing = this.subscribed.get(sentinelId);
21686
+ if (existing) return existing;
21687
+ const entry = this.catalog.get(sentinelId);
21688
+ if (!entry) {
21689
+ throw new Error(`sentinel-registry: unknown sentinel ${sentinelId}`);
21690
+ }
21691
+ const instance = entry.factory();
21692
+ await instance.subscribe(context);
21693
+ this.subscribed.set(sentinelId, instance);
21694
+ return instance;
21695
+ }
21696
+ /**
21697
+ * Unsubscribe. Idempotent: unsubscribing an unsubscribed sentinel
21698
+ * returns false without throwing. Returns true when an active
21699
+ * subscription was torn down.
21700
+ */
21701
+ async unsubscribe(sentinelId) {
21702
+ const instance = this.subscribed.get(sentinelId);
21703
+ if (!instance) return false;
21704
+ try {
21705
+ await instance.unsubscribe();
21706
+ } finally {
21707
+ this.subscribed.delete(sentinelId);
21708
+ }
21709
+ return true;
21710
+ }
21711
+ /**
21712
+ * Snapshot of subscribed sentinels for the dispatcher's tick path.
21713
+ * Returned as an array so the dispatcher can iterate without holding
21714
+ * the map under modification.
21715
+ */
21716
+ snapshotSubscribed() {
21717
+ return [...this.subscribed.entries()].map(([sentinelId, sentinel]) => ({
21718
+ sentinelId,
21719
+ sentinel
21720
+ }));
21721
+ }
21722
+ /**
21723
+ * Tear down every subscription. Called by the dispatcher on
21724
+ * fortress-shutdown. Best-effort: a failing unsubscribe does not
21725
+ * abort the rest.
21726
+ */
21727
+ async unsubscribeAll() {
21728
+ const ids = [...this.subscribed.keys()];
21729
+ for (const id of ids) {
21730
+ try {
21731
+ await this.unsubscribe(id);
21732
+ } catch {
21733
+ }
21734
+ }
21735
+ }
21736
+ };
21737
+ }
21738
+ });
21739
+ var DEFAULT_TICK_INTERVAL_MS, SentinelDispatcher;
21740
+ var init_sentinel_dispatcher = __esm({
21741
+ "src/sentinel/sentinel-dispatcher.ts"() {
21742
+ init_types3();
21743
+ DEFAULT_TICK_INTERVAL_MS = 6e4;
21744
+ SentinelDispatcher = class {
21745
+ registry;
21746
+ findingStore;
21747
+ auditLog;
21748
+ fortressId;
21749
+ identityId;
21750
+ now;
21751
+ tickIntervalMs;
21752
+ listeners = /* @__PURE__ */ new Set();
21753
+ tickTimer = null;
21754
+ tickInFlight = false;
21755
+ constructor(deps) {
21756
+ this.registry = deps.registry;
21757
+ this.findingStore = deps.findingStore;
21758
+ this.auditLog = deps.auditLog;
21759
+ this.fortressId = deps.fortressId;
21760
+ this.identityId = deps.identityId;
21761
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
21762
+ this.tickIntervalMs = deps.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
21763
+ }
21764
+ /** Read-only view of the registry. Convenience for route handlers. */
21765
+ getRegistry() {
21766
+ return this.registry;
21767
+ }
21768
+ /** Read-only view of the finding store. Convenience for route handlers. */
21769
+ getFindingStore() {
21770
+ return this.findingStore;
21771
+ }
21772
+ /**
21773
+ * Subscribe an in-process listener. Returns an unsubscribe fn.
21774
+ */
21775
+ onEvent(listener) {
21776
+ this.listeners.add(listener);
21777
+ return () => this.listeners.delete(listener);
21778
+ }
21779
+ /**
21780
+ * Subscribe a sentinel to this fortress + emit the
21781
+ * `sentinel_subscribed` audit event. Wraps `registry.subscribe()` so
21782
+ * the audit emission lives at the dispatcher boundary (the
21783
+ * fortress-aware site).
21784
+ */
21785
+ async subscribeSentinel(sentinelId, contextOverrides) {
21786
+ const context = {
21787
+ fortressId: this.fortressId,
21788
+ auditLog: this.auditLog,
21789
+ now: this.now,
21790
+ ...contextOverrides ?? {}
21791
+ };
21792
+ const sentinel = await this.registry.subscribe(sentinelId, context);
21793
+ this.auditLog.append(
21794
+ "l2",
21795
+ SENTINEL_AUDIT_OPS.SUBSCRIBED,
21796
+ this.identityId,
21797
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
21798
+ );
21799
+ return sentinel;
21800
+ }
21801
+ /**
21802
+ * Unsubscribe + emit `sentinel_unsubscribed`. Returns true when an
21803
+ * active subscription was torn down. Audit fires only on successful
21804
+ * removal.
21805
+ */
21806
+ async unsubscribeSentinel(sentinelId) {
21807
+ const removed = await this.registry.unsubscribe(sentinelId);
21808
+ if (removed) {
21809
+ this.auditLog.append(
21810
+ "l2",
21811
+ SENTINEL_AUDIT_OPS.UNSUBSCRIBED,
21812
+ this.identityId,
21813
+ { sentinel_id: sentinelId, fortress_id: this.fortressId }
21814
+ );
21815
+ }
21816
+ return removed;
21817
+ }
21818
+ /**
21819
+ * Run one evaluation pass over every subscribed sentinel. Used by
21820
+ * the auto-tick AND by tests that want a synchronous evaluation
21821
+ * gate. Returns the findings produced this tick (already persisted
21822
+ * + audit-logged + emitted).
21823
+ */
21824
+ async tick() {
21825
+ if (this.tickInFlight) return [];
21826
+ this.tickInFlight = true;
21827
+ try {
21828
+ const subscribed = this.registry.snapshotSubscribed();
21829
+ const findings = [];
21830
+ for (const { sentinelId, sentinel } of subscribed) {
21831
+ try {
21832
+ const tickFindings = await sentinel.evaluate();
21833
+ for (const finding of tickFindings) {
21834
+ const stamped = await this.routeFinding(sentinelId, finding);
21835
+ findings.push(stamped);
21836
+ }
21837
+ } catch (err) {
21838
+ const errorMessage = err instanceof Error ? err.message : String(err);
21839
+ const observedAt = this.now().toISOString();
21840
+ this.auditLog.append(
21841
+ "l2",
21842
+ SENTINEL_AUDIT_OPS.EVALUATION_FAILED,
21843
+ this.identityId,
21844
+ {
21845
+ sentinel_id: sentinelId,
21846
+ fortress_id: this.fortressId,
21847
+ error_message: errorMessage
21848
+ },
21849
+ "failure"
21850
+ );
21851
+ this.emit({
21852
+ type: "evaluation_failed",
21853
+ sentinel_id: sentinelId,
21854
+ error_message: errorMessage,
21855
+ observed_at: observedAt
21856
+ });
21857
+ }
21858
+ }
21859
+ return findings;
21860
+ } finally {
21861
+ this.tickInFlight = false;
21862
+ }
21863
+ }
21864
+ /**
21865
+ * Start the auto-tick loop. No-op when tickIntervalMs is 0 or when
21866
+ * already started. Tests typically leave auto-tick off and call
21867
+ * `tick()` directly.
21868
+ */
21869
+ start() {
21870
+ if (this.tickTimer !== null) return;
21871
+ if (this.tickIntervalMs <= 0) return;
21872
+ this.tickTimer = setInterval(() => {
21873
+ void this.tick();
21874
+ }, this.tickIntervalMs);
21875
+ if (typeof this.tickTimer.unref === "function") {
21876
+ this.tickTimer.unref();
21877
+ }
21878
+ }
21879
+ /** Stop the auto-tick loop. Idempotent. */
21880
+ stop() {
21881
+ if (this.tickTimer === null) return;
21882
+ clearInterval(this.tickTimer);
21883
+ this.tickTimer = null;
21884
+ }
21885
+ /**
21886
+ * Tear down every subscription + stop the tick loop. Called on
21887
+ * fortress shutdown.
21888
+ */
21889
+ async dispose() {
21890
+ this.stop();
21891
+ await this.registry.unsubscribeAll();
21892
+ this.listeners.clear();
21893
+ }
21894
+ async routeFinding(sentinelId, raw) {
21895
+ const stamped = {
21896
+ ...raw,
21897
+ finding_id: raw.finding_id || randomUUID(),
21898
+ sentinel_id: sentinelId,
21899
+ fortress_id: this.fortressId,
21900
+ observed_at: raw.observed_at || this.now().toISOString()
21901
+ };
21902
+ await this.findingStore.saveFinding(stamped);
21903
+ this.auditLog.append(
21904
+ "l2",
21905
+ SENTINEL_AUDIT_OPS.FINDING_EMITTED,
21906
+ this.identityId,
21907
+ {
21908
+ sentinel_id: sentinelId,
21909
+ finding_id: stamped.finding_id,
21910
+ severity: stamped.severity,
21911
+ ...stamped.agent_id !== void 0 ? { agent_id: stamped.agent_id } : {},
21912
+ evidence_audit_ids: stamped.evidence_audit_ids,
21913
+ fortress_id: this.fortressId
21914
+ }
21915
+ );
21916
+ this.emit({ type: "finding", finding: stamped });
21917
+ return stamped;
21918
+ }
21919
+ emit(event) {
21920
+ for (const listener of this.listeners) {
21921
+ try {
21922
+ listener(event);
21923
+ } catch {
21924
+ }
21925
+ }
21926
+ }
21927
+ };
21928
+ }
21929
+ });
21930
+
21931
+ // src/sentinel/sentinel.ts
21932
+ var Sentinel;
21933
+ var init_sentinel = __esm({
21934
+ "src/sentinel/sentinel.ts"() {
21935
+ Sentinel = class {
21936
+ /**
21937
+ * Bind the sentinel to a fortress context. Called once on
21938
+ * subscribe. Default implementation stores the context on `this`;
21939
+ * sentinels that need additional setup (e.g. priming a baseline
21940
+ * cache) override.
21941
+ */
21942
+ async subscribe(context) {
21943
+ this.context = context;
21944
+ }
21945
+ /**
21946
+ * Tear down. Default implementation clears the context; subclasses
21947
+ * that hold timers or external handles override.
21948
+ */
21949
+ async unsubscribe() {
21950
+ this.context = void 0;
21951
+ }
21952
+ context;
21953
+ /** Internal helper: assert subscribed before evaluation. */
21954
+ requireContext() {
21955
+ if (!this.context) {
21956
+ throw new Error(
21957
+ `sentinel ${this.sentinelId}: evaluate() called before subscribe()`
21958
+ );
21959
+ }
21960
+ return this.context;
21961
+ }
21962
+ };
21963
+ }
21964
+ });
21965
+
21966
+ // src/sentinel/sentinels/egress-volume-watcher.ts
21967
+ var EGRESS_VOLUME_SENTINEL_ID, WARN_SIGMA, ALERT_SIGMA, BASELINE_WINDOWS, QUERY_LIMIT, EgressVolumeWatcher;
21968
+ var init_egress_volume_watcher = __esm({
21969
+ "src/sentinel/sentinels/egress-volume-watcher.ts"() {
21970
+ init_sentinel();
21971
+ init_types3();
21972
+ EGRESS_VOLUME_SENTINEL_ID = "egress-volume";
21973
+ WARN_SIGMA = 3;
21974
+ ALERT_SIGMA = 6;
21975
+ BASELINE_WINDOWS = 7;
21976
+ QUERY_LIMIT = 1e4;
21977
+ EgressVolumeWatcher = class extends Sentinel {
21978
+ sentinelId = EGRESS_VOLUME_SENTINEL_ID;
21979
+ description = "Watches outbound proxy-call volume per upstream server. Emits warn/alert when current 24h volume exceeds the rolling 7-day baseline by 3x or 6x standard deviations.";
21980
+ /** Servers we have already produced an `info` baseline-established finding for. */
21981
+ baselineEstablished = /* @__PURE__ */ new Set();
21982
+ async evaluate() {
21983
+ const ctx = this.requireContext();
21984
+ const now = ctx.now();
21985
+ const windowMs = 24 * 60 * 60 * 1e3;
21986
+ const windowSpanMs = (BASELINE_WINDOWS + 1) * windowMs;
21987
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
21988
+ const queryResult = await ctx.auditLog.query({
21989
+ since: sinceIso,
21990
+ layer: "l2",
21991
+ limit: QUERY_LIMIT
21992
+ });
21993
+ const entries = queryResult.entries.filter(isProxyCallAuditEntry);
21994
+ const byServer = /* @__PURE__ */ new Map();
21995
+ for (const entry of entries) {
21996
+ const server = proxyServerFromAuditEntry(entry);
21997
+ if (server === null) continue;
21998
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
21999
+ if (auditAge < 0) continue;
22000
+ const windowIdx = Math.floor(auditAge / windowMs);
22001
+ if (windowIdx > BASELINE_WINDOWS) continue;
22002
+ let snapshot = byServer.get(server);
22003
+ if (!snapshot) {
22004
+ snapshot = { windows: [] };
22005
+ for (let i = 0; i <= BASELINE_WINDOWS; i += 1) {
22006
+ snapshot.windows.push({ count: 0, evidence_audit_ids: [] });
22007
+ }
22008
+ byServer.set(server, snapshot);
22009
+ }
22010
+ const bucket = snapshot.windows[windowIdx];
22011
+ bucket.count += 1;
22012
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22013
+ bucket.evidence_audit_ids.push(`${entry.timestamp}:${entry.operation}`);
22014
+ }
22015
+ }
22016
+ const findings = [];
22017
+ for (const [server, snapshot] of byServer.entries()) {
22018
+ const finding = this.evaluateServer(server, snapshot, now);
22019
+ if (finding) findings.push(finding);
22020
+ }
22021
+ return findings;
22022
+ }
22023
+ /** Reset baseline-established memoization. Tests use this between runs. */
22024
+ resetBaselineMemo() {
22025
+ this.baselineEstablished.clear();
22026
+ }
22027
+ evaluateServer(server, snapshot, now) {
22028
+ const currentWindow = snapshot.windows[0];
22029
+ const baselineWindows = snapshot.windows.slice(1);
22030
+ const populatedBaselineWindows = baselineWindows.filter((w) => w.count > 0).length;
22031
+ if (populatedBaselineWindows < BASELINE_WINDOWS) {
22032
+ if (this.baselineEstablished.has(server)) return null;
22033
+ if (populatedBaselineWindows === 0 && currentWindow.count === 0) {
22034
+ return null;
22035
+ }
22036
+ return null;
22037
+ }
22038
+ const baselineCounts = baselineWindows.map((w) => w.count);
22039
+ const mean = baselineCounts.reduce((sum, c) => sum + c, 0) / baselineCounts.length;
22040
+ const variance = baselineCounts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / baselineCounts.length;
22041
+ const stddev = Math.sqrt(variance);
22042
+ const wasEstablished = this.baselineEstablished.has(server);
22043
+ this.baselineEstablished.add(server);
22044
+ if (!wasEstablished) {
22045
+ return {
22046
+ finding_id: "",
22047
+ sentinel_id: this.sentinelId,
22048
+ severity: "info",
22049
+ summary: `egress-volume baseline established for ${server}: mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS} prior days).`,
22050
+ details: {
22051
+ server,
22052
+ baseline_mean: mean,
22053
+ baseline_stddev: stddev,
22054
+ baseline_windows: baselineCounts,
22055
+ current_count: currentWindow.count
22056
+ },
22057
+ observed_at: now.toISOString(),
22058
+ evidence_audit_ids: [],
22059
+ fortress_id: ""
22060
+ };
22061
+ }
22062
+ const warnThreshold = mean + WARN_SIGMA * stddev;
22063
+ const alertThreshold = mean + ALERT_SIGMA * stddev;
22064
+ if (currentWindow.count > alertThreshold) {
22065
+ return this.buildAnomalyFinding(
22066
+ server,
22067
+ snapshot,
22068
+ mean,
22069
+ stddev,
22070
+ now,
22071
+ "alert",
22072
+ ALERT_SIGMA
22073
+ );
22074
+ }
22075
+ if (currentWindow.count > warnThreshold) {
22076
+ return this.buildAnomalyFinding(
22077
+ server,
22078
+ snapshot,
22079
+ mean,
22080
+ stddev,
22081
+ now,
22082
+ "warn",
22083
+ WARN_SIGMA
22084
+ );
22085
+ }
22086
+ return null;
22087
+ }
22088
+ buildAnomalyFinding(server, snapshot, mean, stddev, now, severity, sigma) {
22089
+ const currentWindow = snapshot.windows[0];
22090
+ const ratio = mean === 0 ? Infinity : currentWindow.count / mean;
22091
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22092
+ const summary = `${server} egress is ${ratioStr}: ${currentWindow.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
22093
+ return {
22094
+ finding_id: "",
22095
+ sentinel_id: this.sentinelId,
22096
+ severity,
22097
+ summary,
22098
+ details: {
22099
+ server,
22100
+ current_count: currentWindow.count,
22101
+ baseline_mean: mean,
22102
+ baseline_stddev: stddev,
22103
+ sigma_threshold: sigma,
22104
+ ratio
22105
+ },
22106
+ observed_at: now.toISOString(),
22107
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
22108
+ fortress_id: ""
22109
+ };
22110
+ }
22111
+ };
22112
+ }
22113
+ });
22114
+
22115
+ // src/sentinel/sentinels/cross-agent-chatter-watcher.ts
22116
+ function pairKey(sender, recipient) {
22117
+ return `${sender}|${recipient}`;
22118
+ }
22119
+ function pairFromKey(key) {
22120
+ const idx = key.indexOf("|");
22121
+ return { sender: key.slice(0, idx), recipient: key.slice(idx + 1) };
22122
+ }
22123
+ function computeNewPartners(byPair) {
22124
+ const currentBySource = /* @__PURE__ */ new Map();
22125
+ const priorBySource = /* @__PURE__ */ new Map();
22126
+ for (const [key, snap] of byPair.entries()) {
22127
+ const { sender, recipient } = pairFromKey(key);
22128
+ if (snap.windows[0] && snap.windows[0].count > 0) {
22129
+ let recipMap = currentBySource.get(sender);
22130
+ if (!recipMap) {
22131
+ recipMap = /* @__PURE__ */ new Map();
22132
+ currentBySource.set(sender, recipMap);
22133
+ }
22134
+ recipMap.set(recipient, snap.windows[0].evidence_audit_ids);
22135
+ }
22136
+ const priorTouched = snap.windows.slice(1).some((w) => w.count > 0);
22137
+ if (priorTouched) {
22138
+ let set = priorBySource.get(sender);
22139
+ if (!set) {
22140
+ set = /* @__PURE__ */ new Set();
22141
+ priorBySource.set(sender, set);
22142
+ }
22143
+ set.add(recipient);
22144
+ }
22145
+ }
22146
+ const out = /* @__PURE__ */ new Map();
22147
+ for (const [sender, recipMap] of currentBySource.entries()) {
22148
+ const prior = priorBySource.get(sender) ?? /* @__PURE__ */ new Set();
22149
+ if (prior.size === 0) {
22150
+ continue;
22151
+ }
22152
+ const newPartners = [];
22153
+ const evidence = [];
22154
+ for (const [recipient, recipEvidence] of recipMap.entries()) {
22155
+ if (!prior.has(recipient)) {
22156
+ newPartners.push(recipient);
22157
+ for (const id of recipEvidence) {
22158
+ if (evidence.length < 50) evidence.push(id);
22159
+ }
22160
+ }
22161
+ }
22162
+ if (newPartners.length === 0) continue;
22163
+ newPartners.sort();
22164
+ out.set(sender, {
22165
+ partners: newPartners,
22166
+ priorPartners: [...prior].sort(),
22167
+ evidenceAuditIds: evidence
22168
+ });
22169
+ }
22170
+ return out;
22171
+ }
22172
+ function extractInterAgentEvents(entries) {
22173
+ const out = [];
22174
+ for (const entry of entries) {
22175
+ const op = entry.operation;
22176
+ if (op === HANDOFF_OP) {
22177
+ const details = entry.details;
22178
+ const sender = optionalString(details, "sender_agent_id");
22179
+ const recipient = optionalString(details, "recipient_agent_id");
22180
+ if (!sender || !recipient || sender === recipient) continue;
22181
+ out.push({
22182
+ sender,
22183
+ recipient,
22184
+ timestampMs: Date.parse(entry.timestamp),
22185
+ auditId: `${entry.timestamp}:${entry.operation}`
22186
+ });
22187
+ continue;
22188
+ }
22189
+ if (CROSS_HARNESS_OPS.has(op)) {
22190
+ const details = entry.details;
22191
+ const sender = optionalString(details, "source_harness") ?? optionalString(details, "source_agent_id");
22192
+ if (!sender) continue;
22193
+ out.push({
22194
+ sender,
22195
+ recipient: OPERATOR_PSEUDO_AGENT,
22196
+ timestampMs: Date.parse(entry.timestamp),
22197
+ auditId: `${entry.timestamp}:${entry.operation}`
22198
+ });
22199
+ }
22200
+ }
22201
+ return out;
22202
+ }
22203
+ function optionalString(details, key) {
22204
+ if (!details) return null;
22205
+ const value = details[key];
22206
+ if (typeof value !== "string" || value.length === 0) return null;
22207
+ return value;
22208
+ }
22209
+ var CROSS_AGENT_CHATTER_SENTINEL_ID, WARN_SIGMA2, ALERT_SIGMA2, BASELINE_WINDOWS2, QUERY_LIMIT2, MULTI_NEW_PARTNER_ALERT_THRESHOLD, OPERATOR_PSEUDO_AGENT, HANDOFF_OP, CROSS_HARNESS_OPS, CrossAgentChatterWatcher;
22210
+ var init_cross_agent_chatter_watcher = __esm({
22211
+ "src/sentinel/sentinels/cross-agent-chatter-watcher.ts"() {
22212
+ init_sentinel();
22213
+ CROSS_AGENT_CHATTER_SENTINEL_ID = "cross-agent-chatter";
22214
+ WARN_SIGMA2 = 3;
22215
+ ALERT_SIGMA2 = 6;
22216
+ BASELINE_WINDOWS2 = 7;
22217
+ QUERY_LIMIT2 = 1e4;
22218
+ MULTI_NEW_PARTNER_ALERT_THRESHOLD = 3;
22219
+ OPERATOR_PSEUDO_AGENT = "operator";
22220
+ HANDOFF_OP = "v1.1_local_handoff";
22221
+ CROSS_HARNESS_OPS = /* @__PURE__ */ new Set([
22222
+ "cross_harness_approval_aggregated",
22223
+ "cross_harness_approval_resolved"
22224
+ ]);
22225
+ CrossAgentChatterWatcher = class extends Sentinel {
22226
+ sentinelId = CROSS_AGENT_CHATTER_SENTINEL_ID;
22227
+ description = "Watches inter-agent communication patterns. Surfaces per-pair rate spikes (3 or 6 sigma over the rolling 7-day baseline) and new-partner appearances. Escalates to alert when one source agent picks up 3 or more new partners in 24h (lateral-movement shape).";
22228
+ /** Pair keys we have already produced a baseline-established info finding for. */
22229
+ baselineEstablished = /* @__PURE__ */ new Set();
22230
+ async evaluate() {
22231
+ const ctx = this.requireContext();
22232
+ const now = ctx.now();
22233
+ const windowMs = 24 * 60 * 60 * 1e3;
22234
+ const windowSpanMs = (BASELINE_WINDOWS2 + 1) * windowMs;
22235
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22236
+ const queryResult = await ctx.auditLog.query({
22237
+ since: sinceIso,
22238
+ layer: "l2",
22239
+ limit: QUERY_LIMIT2
22240
+ });
22241
+ const events = extractInterAgentEvents(queryResult.entries);
22242
+ const byPair = /* @__PURE__ */ new Map();
22243
+ for (const event of events) {
22244
+ const auditAgeMs = now.getTime() - event.timestampMs;
22245
+ if (auditAgeMs < 0) continue;
22246
+ const windowIdx = Math.floor(auditAgeMs / windowMs);
22247
+ if (windowIdx > BASELINE_WINDOWS2) continue;
22248
+ const key = pairKey(event.sender, event.recipient);
22249
+ let snap = byPair.get(key);
22250
+ if (!snap) {
22251
+ snap = { windows: [] };
22252
+ for (let i = 0; i <= BASELINE_WINDOWS2; i += 1) {
22253
+ snap.windows.push({ count: 0, evidence_audit_ids: [] });
22254
+ }
22255
+ byPair.set(key, snap);
22256
+ }
22257
+ const bucket = snap.windows[windowIdx];
22258
+ bucket.count += 1;
22259
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22260
+ bucket.evidence_audit_ids.push(event.auditId);
22261
+ }
22262
+ }
22263
+ const findings = [];
22264
+ for (const [key, snap] of byPair.entries()) {
22265
+ const finding = this.evaluatePair(key, snap, now);
22266
+ if (finding) findings.push(finding);
22267
+ }
22268
+ const newPartnersBySource = computeNewPartners(byPair);
22269
+ for (const [source, partners] of newPartnersBySource.entries()) {
22270
+ const finding = this.buildNewPartnerFinding(source, partners, now);
22271
+ if (finding) findings.push(finding);
22272
+ }
22273
+ return findings;
22274
+ }
22275
+ /** Reset baseline-established memoization. Tests use this between runs. */
22276
+ resetBaselineMemo() {
22277
+ this.baselineEstablished.clear();
22278
+ }
22279
+ evaluatePair(key, snap, now) {
22280
+ const currentWindow = snap.windows[0];
22281
+ const baselineWindows = snap.windows.slice(1);
22282
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
22283
+ if (populated < BASELINE_WINDOWS2) {
22284
+ return null;
22285
+ }
22286
+ const counts = baselineWindows.map((w) => w.count);
22287
+ const mean = counts.reduce((s, c) => s + c, 0) / counts.length;
22288
+ const variance = counts.reduce((s, c) => s + (c - mean) ** 2, 0) / counts.length;
22289
+ const stddev = Math.sqrt(variance);
22290
+ const wasEstablished = this.baselineEstablished.has(key);
22291
+ this.baselineEstablished.add(key);
22292
+ if (!wasEstablished) {
22293
+ const pair = pairFromKey(key);
22294
+ return {
22295
+ finding_id: "",
22296
+ sentinel_id: this.sentinelId,
22297
+ severity: "info",
22298
+ summary: `cross-agent-chatter baseline established for ${pair.sender} -> ${pair.recipient}: mean ${mean.toFixed(1)} msgs/24h, stddev ${stddev.toFixed(1)} (over ${BASELINE_WINDOWS2} prior days).`,
22299
+ details: {
22300
+ sender_agent_id: pair.sender,
22301
+ recipient_agent_id: pair.recipient,
22302
+ baseline_mean: mean,
22303
+ baseline_stddev: stddev,
22304
+ baseline_windows: counts,
22305
+ current_count: currentWindow.count
22306
+ },
22307
+ observed_at: now.toISOString(),
22308
+ evidence_audit_ids: [],
22309
+ fortress_id: ""
22310
+ };
22311
+ }
22312
+ const warnThreshold = mean + WARN_SIGMA2 * stddev;
22313
+ const alertThreshold = mean + ALERT_SIGMA2 * stddev;
22314
+ if (currentWindow.count > alertThreshold) {
22315
+ return this.buildRateSpike(key, snap, mean, stddev, now, "alert", ALERT_SIGMA2);
22316
+ }
22317
+ if (currentWindow.count > warnThreshold) {
22318
+ return this.buildRateSpike(key, snap, mean, stddev, now, "warn", WARN_SIGMA2);
22319
+ }
22320
+ return null;
22321
+ }
22322
+ buildRateSpike(key, snap, mean, stddev, now, severity, sigma) {
22323
+ const pair = pairFromKey(key);
22324
+ const cur = snap.windows[0];
22325
+ const ratio = mean === 0 ? Infinity : cur.count / mean;
22326
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22327
+ const summary = `${pair.sender} -> ${pair.recipient} chatter rate is ${ratioStr}: ${cur.count} cross-agent messages in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma threshold.`;
22328
+ return {
22329
+ finding_id: "",
22330
+ sentinel_id: this.sentinelId,
22331
+ severity,
22332
+ summary,
22333
+ details: {
22334
+ sender_agent_id: pair.sender,
22335
+ recipient_agent_id: pair.recipient,
22336
+ current_count: cur.count,
22337
+ baseline_mean: mean,
22338
+ baseline_stddev: stddev,
22339
+ sigma_threshold: sigma,
22340
+ ratio
22341
+ },
22342
+ observed_at: now.toISOString(),
22343
+ agent_id: pair.sender,
22344
+ evidence_audit_ids: cur.evidence_audit_ids,
22345
+ fortress_id: ""
22346
+ };
22347
+ }
22348
+ buildNewPartnerFinding(source, info, now) {
22349
+ if (info.partners.length === 0) return null;
22350
+ const severity = info.partners.length >= MULTI_NEW_PARTNER_ALERT_THRESHOLD ? "alert" : "warn";
22351
+ const partnerList = info.partners.join(", ");
22352
+ const baselinePartnerList = info.priorPartners.length === 0 ? "no prior partners" : info.priorPartners.join(", ");
22353
+ const summary = severity === "alert" ? `${source} began communicating with ${info.partners.length} new partners in 24h (${partnerList}). Prior partners: ${baselinePartnerList}. Multi-new-partner pattern crossed alert threshold (>=${MULTI_NEW_PARTNER_ALERT_THRESHOLD}).` : `${source} began communicating with a new partner today: ${partnerList}. Prior partners: ${baselinePartnerList}.`;
22354
+ return {
22355
+ finding_id: "",
22356
+ sentinel_id: this.sentinelId,
22357
+ severity,
22358
+ summary,
22359
+ details: {
22360
+ sender_agent_id: source,
22361
+ new_partners: info.partners,
22362
+ prior_partners: info.priorPartners,
22363
+ new_partner_count: info.partners.length,
22364
+ multi_new_partner_threshold: MULTI_NEW_PARTNER_ALERT_THRESHOLD
22365
+ },
22366
+ observed_at: now.toISOString(),
22367
+ agent_id: source,
22368
+ evidence_audit_ids: info.evidenceAuditIds,
22369
+ fortress_id: ""
22370
+ };
22371
+ }
22372
+ };
22373
+ }
22374
+ });
22375
+
22376
+ // src/sentinel/sentinels/credential-usage-watcher.ts
22377
+ function isCredentialAuditEntry(entry) {
22378
+ if (entry.result !== "success") return false;
22379
+ return entry.operation === BROKER_SECRET_READ_OP || entry.operation === BROKER_TOKEN_ISSUED_OP;
22380
+ }
22381
+ function extractAgentId(entry) {
22382
+ const details = entry.details;
22383
+ if (!details) return null;
22384
+ const agent = details["agent"];
22385
+ return typeof agent === "string" && agent.length > 0 ? agent : null;
22386
+ }
22387
+ function extractSecretId(entry) {
22388
+ const details = entry.details;
22389
+ if (!details) return null;
22390
+ const secret = details["secret"];
22391
+ return typeof secret === "string" && secret.length > 0 ? secret : null;
22392
+ }
22393
+ function enumerateUnorderedPairs(secrets) {
22394
+ const out = /* @__PURE__ */ new Set();
22395
+ const arr = [...secrets].sort();
22396
+ for (let i = 0; i < arr.length; i += 1) {
22397
+ for (let j = i + 1; j < arr.length; j += 1) {
22398
+ out.add(`${arr[i]}\0${arr[j]}`);
22399
+ }
22400
+ }
22401
+ return out;
22402
+ }
22403
+ function buildNewPairSummary(agentId, newPairs) {
22404
+ const first = newPairs[0];
22405
+ if (newPairs.length === 1) {
22406
+ return `${agentId} agent used ${first[0]} and ${first[1]} together for the first time today. This combination does not appear in historical sessions.`;
22407
+ }
22408
+ return `${agentId} agent introduced ${newPairs.length} unfamiliar credential pairs in the last 24h. First: ${first[0]} + ${first[1]}. This pattern is new for this agent.`;
22409
+ }
22410
+ var CREDENTIAL_USAGE_SENTINEL_ID, WARN_SIGMA3, ALERT_SIGMA3, NEW_PAIR_ALERT_COUNT, BASELINE_WINDOWS3, QUERY_LIMIT3, BROKER_SECRET_READ_OP, BROKER_TOKEN_ISSUED_OP, CredentialUsageWatcher;
22411
+ var init_credential_usage_watcher = __esm({
22412
+ "src/sentinel/sentinels/credential-usage-watcher.ts"() {
22413
+ init_sentinel();
22414
+ CREDENTIAL_USAGE_SENTINEL_ID = "credential-usage";
22415
+ WARN_SIGMA3 = 3;
22416
+ ALERT_SIGMA3 = 6;
22417
+ NEW_PAIR_ALERT_COUNT = 3;
22418
+ BASELINE_WINDOWS3 = 7;
22419
+ QUERY_LIMIT3 = 2e4;
22420
+ BROKER_SECRET_READ_OP = "broker_secret_read";
22421
+ BROKER_TOKEN_ISSUED_OP = "broker_token_issued";
22422
+ CredentialUsageWatcher = class extends Sentinel {
22423
+ sentinelId = CREDENTIAL_USAGE_SENTINEL_ID;
22424
+ description = "Watches per-agent credential reads. Emits warn/alert when a specific (agent, secret) usage rate exceeds the rolling 7-day baseline, or when an agent uses an unfamiliar combination of secrets together in one 24h window.";
22425
+ /**
22426
+ * Memoization of (agent, secret) pairs whose baseline has been
22427
+ * established. Same shape Phi-1 uses to avoid re-emitting `info`
22428
+ * findings on every tick after a baseline first establishes.
22429
+ *
22430
+ * Phi-2 deliberately does NOT emit `info` findings: per-pair
22431
+ * baselines on a busy fortress would be too noisy. The memo is
22432
+ * kept here for parity with Phi-1's reset hook so tests can clear
22433
+ * state between runs.
22434
+ */
22435
+ baselineEstablished = /* @__PURE__ */ new Set();
22436
+ /** Reset memoization. Tests use this between runs. */
22437
+ resetBaselineMemo() {
22438
+ this.baselineEstablished.clear();
22439
+ }
22440
+ async evaluate() {
22441
+ const ctx = this.requireContext();
22442
+ const now = ctx.now();
22443
+ const windowMs = 24 * 60 * 60 * 1e3;
22444
+ const windowSpanMs = (BASELINE_WINDOWS3 + 1) * windowMs;
22445
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22446
+ const queryResult = await ctx.auditLog.query({
22447
+ since: sinceIso,
22448
+ layer: "l3",
22449
+ limit: QUERY_LIMIT3
22450
+ });
22451
+ const entries = queryResult.entries.filter(isCredentialAuditEntry);
22452
+ const byPair = /* @__PURE__ */ new Map();
22453
+ const byAgent = /* @__PURE__ */ new Map();
22454
+ for (const entry of entries) {
22455
+ const agentId = extractAgentId(entry);
22456
+ const secretId = extractSecretId(entry);
22457
+ if (agentId === null || secretId === null) continue;
22458
+ const auditAge = now.getTime() - new Date(entry.timestamp).getTime();
22459
+ if (auditAge < 0) continue;
22460
+ const windowIdx = Math.floor(auditAge / windowMs);
22461
+ if (windowIdx > BASELINE_WINDOWS3) continue;
22462
+ const pairKey2 = `${agentId}\0${secretId}`;
22463
+ let pairSnapshot = byPair.get(pairKey2);
22464
+ if (!pairSnapshot) {
22465
+ pairSnapshot = {
22466
+ windows: Array.from({ length: BASELINE_WINDOWS3 + 1 }, () => ({
22467
+ count: 0,
22468
+ evidence_audit_ids: []
22469
+ }))
22470
+ };
22471
+ byPair.set(pairKey2, pairSnapshot);
22472
+ }
22473
+ const bucket = pairSnapshot.windows[windowIdx];
22474
+ bucket.count += 1;
22475
+ if (windowIdx === 0 && bucket.evidence_audit_ids.length < 50) {
22476
+ bucket.evidence_audit_ids.push(
22477
+ `${entry.timestamp}:${entry.operation}`
22478
+ );
22479
+ }
22480
+ let agentState = byAgent.get(agentId);
22481
+ if (!agentState) {
22482
+ agentState = {
22483
+ currentSecrets: /* @__PURE__ */ new Set(),
22484
+ currentEvidence: [],
22485
+ baselineSecretsByWindow: Array.from(
22486
+ { length: BASELINE_WINDOWS3 },
22487
+ () => /* @__PURE__ */ new Set()
22488
+ ),
22489
+ baselinePopulatedWindows: 0
22490
+ };
22491
+ byAgent.set(agentId, agentState);
22492
+ }
22493
+ if (windowIdx === 0) {
22494
+ agentState.currentSecrets.add(secretId);
22495
+ if (agentState.currentEvidence.length < 50) {
22496
+ agentState.currentEvidence.push(
22497
+ `${entry.timestamp}:${entry.operation}`
22498
+ );
22499
+ }
22500
+ } else {
22501
+ const baselineIdx = windowIdx - 1;
22502
+ agentState.baselineSecretsByWindow[baselineIdx].add(secretId);
22503
+ }
22504
+ }
22505
+ const findings = [];
22506
+ for (const [pairKey2, snapshot] of byPair.entries()) {
22507
+ const [agentId, secretId] = pairKey2.split("\0");
22508
+ const finding = this.evaluateRateSpike(
22509
+ agentId,
22510
+ secretId,
22511
+ snapshot,
22512
+ now
22513
+ );
22514
+ if (finding) findings.push(finding);
22515
+ }
22516
+ for (const [agentId, agentState] of byAgent.entries()) {
22517
+ agentState.baselinePopulatedWindows = agentState.baselineSecretsByWindow.filter((s) => s.size > 0).length;
22518
+ const finding = this.evaluateNewPairs(agentId, agentState, now);
22519
+ if (finding) findings.push(finding);
22520
+ }
22521
+ return findings;
22522
+ }
22523
+ evaluateRateSpike(agentId, secretId, snapshot, now) {
22524
+ const currentWindow = snapshot.windows[0];
22525
+ const baselineWindows = snapshot.windows.slice(1);
22526
+ const populated = baselineWindows.filter((w) => w.count > 0).length;
22527
+ if (populated < BASELINE_WINDOWS3) {
22528
+ return null;
22529
+ }
22530
+ const counts = baselineWindows.map((w) => w.count);
22531
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
22532
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
22533
+ const stddev = Math.sqrt(variance);
22534
+ const pairKey2 = `${agentId}\0${secretId}`;
22535
+ this.baselineEstablished.add(pairKey2);
22536
+ const warnThreshold = mean + WARN_SIGMA3 * stddev;
22537
+ const alertThreshold = mean + ALERT_SIGMA3 * stddev;
22538
+ if (currentWindow.count > alertThreshold) {
22539
+ return this.buildRateFinding(
22540
+ agentId,
22541
+ secretId,
22542
+ currentWindow,
22543
+ mean,
22544
+ stddev,
22545
+ now,
22546
+ "alert",
22547
+ ALERT_SIGMA3
22548
+ );
22549
+ }
22550
+ if (currentWindow.count > warnThreshold) {
22551
+ return this.buildRateFinding(
22552
+ agentId,
22553
+ secretId,
22554
+ currentWindow,
22555
+ mean,
22556
+ stddev,
22557
+ now,
22558
+ "warn",
22559
+ WARN_SIGMA3
22560
+ );
22561
+ }
22562
+ return null;
22563
+ }
22564
+ buildRateFinding(agentId, secretId, currentWindow, mean, stddev, now, severity, sigma) {
22565
+ const ratio = mean === 0 ? Number.POSITIVE_INFINITY : currentWindow.count / mean;
22566
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22567
+ const summary = `${agentId} agent used ${secretId} ${currentWindow.count} times in last 24h, baseline ${mean.toFixed(1)} +/- ${stddev.toFixed(1)} (${ratioStr}). Crossed +${sigma} sigma threshold.`;
22568
+ return {
22569
+ finding_id: "",
22570
+ sentinel_id: this.sentinelId,
22571
+ severity,
22572
+ agent_id: agentId,
22573
+ summary,
22574
+ details: {
22575
+ agent_id: agentId,
22576
+ secret_id: secretId,
22577
+ current_count: currentWindow.count,
22578
+ baseline_mean: mean,
22579
+ baseline_stddev: stddev,
22580
+ sigma_threshold: sigma,
22581
+ ratio: Number.isFinite(ratio) ? ratio : null
22582
+ },
22583
+ observed_at: now.toISOString(),
22584
+ evidence_audit_ids: currentWindow.evidence_audit_ids,
22585
+ fortress_id: ""
22586
+ };
22587
+ }
22588
+ evaluateNewPairs(agentId, state, now) {
22589
+ if (state.baselinePopulatedWindows < BASELINE_WINDOWS3) {
22590
+ return null;
22591
+ }
22592
+ if (state.currentSecrets.size < 2) return null;
22593
+ const currentPairs = enumerateUnorderedPairs(state.currentSecrets);
22594
+ const historicalPairs = /* @__PURE__ */ new Set();
22595
+ for (const secretSet of state.baselineSecretsByWindow) {
22596
+ for (const pair of enumerateUnorderedPairs(secretSet)) {
22597
+ historicalPairs.add(pair);
22598
+ }
22599
+ }
22600
+ const newPairs = [];
22601
+ for (const pair of currentPairs) {
22602
+ if (historicalPairs.has(pair)) continue;
22603
+ const [a, b] = pair.split("\0");
22604
+ newPairs.push([a, b]);
22605
+ }
22606
+ if (newPairs.length === 0) return null;
22607
+ const severity = newPairs.length >= NEW_PAIR_ALERT_COUNT ? "alert" : "warn";
22608
+ const summary = buildNewPairSummary(agentId, newPairs);
22609
+ return {
22610
+ finding_id: "",
22611
+ sentinel_id: this.sentinelId,
22612
+ severity,
22613
+ agent_id: agentId,
22614
+ summary,
22615
+ details: {
22616
+ agent_id: agentId,
22617
+ new_pairs: newPairs,
22618
+ new_pair_count: newPairs.length,
22619
+ historical_pair_count: historicalPairs.size,
22620
+ current_pair_count: currentPairs.size
22621
+ },
22622
+ observed_at: now.toISOString(),
22623
+ evidence_audit_ids: state.currentEvidence,
22624
+ fortress_id: ""
22625
+ };
22626
+ }
22627
+ };
22628
+ }
22629
+ });
22630
+
22631
+ // src/sentinel/sentinels/suspicious-tool-call-detector.ts
22632
+ function countTruncatedValues(args) {
22633
+ let n = 0;
22634
+ for (const v of Object.values(args)) {
22635
+ if (typeof v === "string" && v.endsWith("...")) n += 1;
22636
+ }
22637
+ return n;
22638
+ }
22639
+ function countUrlEncoded(value) {
22640
+ const matches = value.match(/%[0-9a-fA-F]{2}/g);
22641
+ return matches ? matches.length : 0;
22642
+ }
22643
+ function longestBase64Run(value) {
22644
+ const matches = value.match(/[A-Za-z0-9+/=]{40,}/g);
22645
+ if (!matches) return 0;
22646
+ return matches.reduce((max, m) => m.length > max ? m.length : max, 0);
22647
+ }
22648
+ function extractArgsSummary(details) {
22649
+ if (!details) return {};
22650
+ const summary = details["args_summary"];
22651
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
22652
+ return summary;
22653
+ }
22654
+ return {};
22655
+ }
22656
+ function truncateSummary2(s) {
22657
+ return s.length > 240 ? s.slice(0, 237) + "..." : s;
22658
+ }
22659
+ var SUSPICIOUS_TOOL_CALL_SENTINEL_ID, GATE_PREFIXES, WARN_SIGMA4, ALERT_SIGMA4, BASELINE_WINDOWS4, ALERT_NOVEL_COMBINATIONS, TASK_WINDOW_MS, TRUNCATION_WARN_THRESHOLD, QUERY_LIMIT4, SIGNATURE_PATTERNS, SuspiciousToolCallDetector;
22660
+ var init_suspicious_tool_call_detector = __esm({
22661
+ "src/sentinel/sentinels/suspicious-tool-call-detector.ts"() {
22662
+ init_sentinel();
22663
+ SUSPICIOUS_TOOL_CALL_SENTINEL_ID = "suspicious-tool-call";
22664
+ GATE_PREFIXES = [
22665
+ "gate_allow:",
22666
+ "gate_allow_proxy:",
22667
+ "gate_deny:",
22668
+ "gate_unclassified:"
22669
+ ];
22670
+ WARN_SIGMA4 = 3;
22671
+ ALERT_SIGMA4 = 6;
22672
+ BASELINE_WINDOWS4 = 7;
22673
+ ALERT_NOVEL_COMBINATIONS = 2;
22674
+ TASK_WINDOW_MS = 60 * 60 * 1e3;
22675
+ TRUNCATION_WARN_THRESHOLD = 5;
22676
+ QUERY_LIMIT4 = 1e4;
22677
+ SIGNATURE_PATTERNS = {
22678
+ /** >=5 percent-encoded sequences in a single visible value. */
22679
+ urlEncodedThreshold: 5,
22680
+ /** >=40 contiguous base64 chars in a single visible value. */
22681
+ base64MinRun: 40,
22682
+ /** Shell metacharacter set. */
22683
+ shellMetacharRegex: /(?:&&|\|\||;|\$\(|`|\|\s)/
22684
+ };
22685
+ SuspiciousToolCallDetector = class extends Sentinel {
22686
+ sentinelId = SUSPICIOUS_TOOL_CALL_SENTINEL_ID;
22687
+ description = "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history. Rule-based heuristics with optional LLM-assist on ambiguous matches.";
22688
+ /** Servers we have already produced an `info` baseline-established finding for. */
22689
+ baselineEstablished = /* @__PURE__ */ new Set();
22690
+ /** Memoized known novel-combination keys (sorted-tools-csv). */
22691
+ knownCombinations = /* @__PURE__ */ new Set();
22692
+ /** Tasks observed where a novel combination already produced a finding. */
22693
+ novelCombinationsReported = /* @__PURE__ */ new Set();
22694
+ async evaluate() {
22695
+ const ctx = this.requireContext();
22696
+ const now = ctx.now();
22697
+ const dayMs = 24 * 60 * 60 * 1e3;
22698
+ const windowSpanMs = (BASELINE_WINDOWS4 + 1) * dayMs;
22699
+ const sinceIso = new Date(now.getTime() - windowSpanMs).toISOString();
22700
+ const queryResult = await ctx.auditLog.query({
22701
+ since: sinceIso,
22702
+ layer: "l2",
22703
+ limit: QUERY_LIMIT4
22704
+ });
22705
+ const observations = [];
22706
+ for (const entry of queryResult.entries) {
22707
+ const obs = this.observationFromEntry(entry);
22708
+ if (obs && obs.ts <= now.getTime()) observations.push(obs);
22709
+ }
22710
+ if (observations.length === 0) return [];
22711
+ const findings = [];
22712
+ const layerAFindings = await this.runLayerA(observations, now, ctx);
22713
+ findings.push(...layerAFindings);
22714
+ const layerBFindings = this.runLayerB(observations, now);
22715
+ findings.push(...layerBFindings);
22716
+ const layerCFindings = this.runLayerC(observations, now);
22717
+ findings.push(...layerCFindings);
22718
+ return findings;
22719
+ }
22720
+ /** Reset memoization between test runs. Mirrors Phi-1's reset hook. */
22721
+ resetMemo() {
22722
+ this.baselineEstablished.clear();
22723
+ this.knownCombinations.clear();
22724
+ this.novelCombinationsReported.clear();
22725
+ }
22726
+ // ── Layer A ───────────────────────────────────────────────────────
22727
+ async runLayerA(observations, now, ctx) {
22728
+ const dayMs = 24 * 60 * 60 * 1e3;
22729
+ const recent = observations.filter(
22730
+ (o) => now.getTime() - o.ts <= dayMs
22731
+ );
22732
+ if (recent.length === 0) return [];
22733
+ const findings = [];
22734
+ const historical = observations.filter(
22735
+ (o) => now.getTime() - o.ts > dayMs
22736
+ );
22737
+ const perTool = /* @__PURE__ */ new Map();
22738
+ const ensureTool = (tool) => {
22739
+ let w = perTool.get(tool);
22740
+ if (!w) {
22741
+ w = {
22742
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
22743
+ count: 0,
22744
+ evidenceIds: []
22745
+ })),
22746
+ knownSignatures: /* @__PURE__ */ new Set()
22747
+ };
22748
+ perTool.set(tool, w);
22749
+ }
22750
+ return w;
22751
+ };
22752
+ for (const o of historical) {
22753
+ ensureTool(o.tool).knownSignatures.add(this.signatureOf(o.argsSummary));
22754
+ }
22755
+ const classify = this.classifyHandle(ctx);
22756
+ for (const obs of recent) {
22757
+ const matches = this.matchSignatures(obs);
22758
+ if (matches.length === 0) continue;
22759
+ const ambiguous = matches.every((m) => m === "base64_chunk");
22760
+ if (ambiguous && classify) {
22761
+ const verdict = await this.consultClassifier(classify, obs);
22762
+ if (verdict !== "suspicious") continue;
22763
+ }
22764
+ findings.push(
22765
+ this.buildLayerAFinding(obs, matches, now, classify ? "llm-assist" : "rule-based")
22766
+ );
22767
+ }
22768
+ for (const obs of recent) {
22769
+ const tool = obs.tool;
22770
+ const sig = this.signatureOf(obs.argsSummary);
22771
+ const known = perTool.get(tool)?.knownSignatures;
22772
+ if (known && known.size > 0 && !known.has(sig)) {
22773
+ findings.push(
22774
+ this.buildNovelSignatureFinding(obs, sig, now)
22775
+ );
22776
+ }
22777
+ }
22778
+ return findings;
22779
+ }
22780
+ matchSignatures(obs) {
22781
+ const out = [];
22782
+ const truncCount = countTruncatedValues(obs.argsSummary);
22783
+ if (truncCount >= TRUNCATION_WARN_THRESHOLD) out.push("truncation_burst");
22784
+ let urlBlob = false;
22785
+ let base64Blob = false;
22786
+ let shellChars = false;
22787
+ for (const value of Object.values(obs.argsSummary)) {
22788
+ if (typeof value !== "string") continue;
22789
+ if (countUrlEncoded(value) >= SIGNATURE_PATTERNS.urlEncodedThreshold) {
22790
+ urlBlob = true;
22791
+ }
22792
+ if (longestBase64Run(value) >= SIGNATURE_PATTERNS.base64MinRun) {
22793
+ base64Blob = true;
22794
+ }
22795
+ if (SIGNATURE_PATTERNS.shellMetacharRegex.test(value)) {
22796
+ shellChars = true;
22797
+ }
22798
+ }
22799
+ if (urlBlob) out.push("url_encoded_blob");
22800
+ if (base64Blob) out.push("base64_chunk");
22801
+ if (shellChars) out.push("shell_metachar");
22802
+ return out;
22803
+ }
22804
+ signatureOf(argsSummary) {
22805
+ return Object.keys(argsSummary).sort().join(",");
22806
+ }
22807
+ buildLayerAFinding(obs, matches, now, detectionPath) {
22808
+ const severity = matches.includes("shell_metachar") ? "alert" : "warn";
22809
+ const summary = `${obs.tool}: tool-call argument matches signature ${matches.join(", ")} (${detectionPath}).`;
22810
+ return {
22811
+ finding_id: "",
22812
+ sentinel_id: this.sentinelId,
22813
+ severity,
22814
+ summary: truncateSummary2(summary),
22815
+ details: {
22816
+ layer: "A",
22817
+ tool: obs.tool,
22818
+ proxy: obs.proxy,
22819
+ signatures: matches,
22820
+ detection_path: detectionPath
22821
+ },
22822
+ observed_at: now.toISOString(),
22823
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
22824
+ fortress_id: ""
22825
+ };
22826
+ }
22827
+ buildNovelSignatureFinding(obs, signature, now) {
22828
+ return {
22829
+ finding_id: "",
22830
+ sentinel_id: this.sentinelId,
22831
+ severity: "warn",
22832
+ summary: truncateSummary2(
22833
+ `${obs.tool}: novel argument-key signature observed (${signature || "<no-args>"}).`
22834
+ ),
22835
+ details: {
22836
+ layer: "A",
22837
+ tool: obs.tool,
22838
+ proxy: obs.proxy,
22839
+ signatures: ["novel_signature"],
22840
+ detection_path: "rule-based",
22841
+ novel_signature: signature
22842
+ },
22843
+ observed_at: now.toISOString(),
22844
+ evidence_audit_ids: [`${obs.entry.timestamp}:${obs.entry.operation}`],
22845
+ fortress_id: ""
22846
+ };
22847
+ }
22848
+ // ── Layer B ───────────────────────────────────────────────────────
22849
+ runLayerB(observations, now) {
22850
+ const dayMs = 24 * 60 * 60 * 1e3;
22851
+ const perTool = /* @__PURE__ */ new Map();
22852
+ for (const obs of observations) {
22853
+ const ageMs = now.getTime() - obs.ts;
22854
+ if (ageMs < 0) continue;
22855
+ const windowIdx = Math.floor(ageMs / dayMs);
22856
+ if (windowIdx > BASELINE_WINDOWS4) continue;
22857
+ let w = perTool.get(obs.tool);
22858
+ if (!w) {
22859
+ w = {
22860
+ windows: Array.from({ length: BASELINE_WINDOWS4 + 1 }, () => ({
22861
+ count: 0,
22862
+ evidenceIds: []
22863
+ })),
22864
+ knownSignatures: /* @__PURE__ */ new Set()
22865
+ };
22866
+ perTool.set(obs.tool, w);
22867
+ }
22868
+ const bucket = w.windows[windowIdx];
22869
+ bucket.count += 1;
22870
+ if (windowIdx === 0 && bucket.evidenceIds.length < 50) {
22871
+ bucket.evidenceIds.push(`${obs.entry.timestamp}:${obs.entry.operation}`);
22872
+ }
22873
+ }
22874
+ const findings = [];
22875
+ for (const [tool, w] of perTool.entries()) {
22876
+ const f = this.evaluateToolFrequency(tool, w, now);
22877
+ if (f) findings.push(f);
22878
+ }
22879
+ return findings;
22880
+ }
22881
+ evaluateToolFrequency(tool, w, now) {
22882
+ const current = w.windows[0];
22883
+ const baseline = w.windows.slice(1);
22884
+ const populated = baseline.filter((b) => b.count > 0).length;
22885
+ if (populated < BASELINE_WINDOWS4) {
22886
+ this.baselineEstablished.add(tool);
22887
+ return null;
22888
+ }
22889
+ const counts = baseline.map((b) => b.count);
22890
+ const mean = counts.reduce((sum, c) => sum + c, 0) / counts.length;
22891
+ const variance = counts.reduce((sum, c) => sum + (c - mean) ** 2, 0) / counts.length;
22892
+ const stddev = Math.sqrt(variance);
22893
+ const wasEstablished = this.baselineEstablished.has(tool);
22894
+ this.baselineEstablished.add(tool);
22895
+ if (!wasEstablished) {
22896
+ return {
22897
+ finding_id: "",
22898
+ sentinel_id: this.sentinelId,
22899
+ severity: "info",
22900
+ summary: truncateSummary2(
22901
+ `${tool}: tool-call baseline established (mean ${mean.toFixed(1)} calls/24h, stddev ${stddev.toFixed(1)} over ${BASELINE_WINDOWS4} prior days).`
22902
+ ),
22903
+ details: {
22904
+ layer: "B",
22905
+ tool,
22906
+ baseline_mean: mean,
22907
+ baseline_stddev: stddev,
22908
+ baseline_counts: counts,
22909
+ current_count: current.count
22910
+ },
22911
+ observed_at: now.toISOString(),
22912
+ evidence_audit_ids: [],
22913
+ fortress_id: ""
22914
+ };
22915
+ }
22916
+ const warnT = mean + WARN_SIGMA4 * stddev;
22917
+ const alertT = mean + ALERT_SIGMA4 * stddev;
22918
+ if (current.count > alertT) {
22919
+ return this.buildLayerBAnomaly(
22920
+ tool,
22921
+ current,
22922
+ mean,
22923
+ stddev,
22924
+ ALERT_SIGMA4,
22925
+ "alert",
22926
+ now
22927
+ );
22928
+ }
22929
+ if (current.count > warnT) {
22930
+ return this.buildLayerBAnomaly(
22931
+ tool,
22932
+ current,
22933
+ mean,
22934
+ stddev,
22935
+ WARN_SIGMA4,
22936
+ "warn",
22937
+ now
22938
+ );
22939
+ }
22940
+ return null;
22941
+ }
22942
+ buildLayerBAnomaly(tool, current, mean, stddev, sigma, severity, now) {
22943
+ const ratio = mean === 0 ? Infinity : current.count / mean;
22944
+ const ratioStr = Number.isFinite(ratio) ? `${ratio.toFixed(1)}x normal` : "no prior baseline";
22945
+ return {
22946
+ finding_id: "",
22947
+ sentinel_id: this.sentinelId,
22948
+ severity,
22949
+ summary: truncateSummary2(
22950
+ `${tool}: tool-call rate is ${ratioStr}: ${current.count} calls in last 24h, baseline ${mean.toFixed(1)} (stddev ${stddev.toFixed(1)}). Crossed +${sigma} sigma.`
22951
+ ),
22952
+ details: {
22953
+ layer: "B",
22954
+ tool,
22955
+ current_count: current.count,
22956
+ baseline_mean: mean,
22957
+ baseline_stddev: stddev,
22958
+ sigma_threshold: sigma,
22959
+ ratio
22960
+ },
22961
+ observed_at: now.toISOString(),
22962
+ evidence_audit_ids: current.evidenceIds,
22963
+ fortress_id: ""
22964
+ };
22965
+ }
22966
+ // ── Layer C ───────────────────────────────────────────────────────
22967
+ runLayerC(observations, now) {
22968
+ const dayMs = 24 * 60 * 60 * 1e3;
22969
+ const sorted = [...observations].sort((a, b) => a.ts - b.ts);
22970
+ const tasks = [];
22971
+ for (const obs of sorted) {
22972
+ const last = tasks[tasks.length - 1];
22973
+ if (!last || obs.ts - last.startTs > TASK_WINDOW_MS) {
22974
+ tasks.push({ startTs: obs.ts, tools: [obs.tool] });
22975
+ continue;
22976
+ }
22977
+ if (!last.tools.includes(obs.tool)) last.tools.push(obs.tool);
22978
+ }
22979
+ const recentTaskKeys = [];
22980
+ const findings = [];
22981
+ for (const task of tasks) {
22982
+ const ageMs = now.getTime() - task.startTs;
22983
+ const key = task.tools.slice().sort().join(",");
22984
+ if (ageMs > dayMs) {
22985
+ this.knownCombinations.add(key);
22986
+ continue;
22987
+ }
22988
+ if (task.tools.length < 2) continue;
22989
+ if (!this.knownCombinations.has(key)) {
22990
+ this.knownCombinations.add(key);
22991
+ if (!this.novelCombinationsReported.has(key)) {
22992
+ this.novelCombinationsReported.add(key);
22993
+ recentTaskKeys.push(key);
22994
+ findings.push(
22995
+ this.buildLayerCFinding(task, key, "warn", now)
22996
+ );
22997
+ }
22998
+ }
22999
+ }
23000
+ if (recentTaskKeys.length >= ALERT_NOVEL_COMBINATIONS) {
23001
+ const aggregate = {
23002
+ finding_id: "",
23003
+ sentinel_id: this.sentinelId,
23004
+ severity: "alert",
23005
+ summary: truncateSummary2(
23006
+ `multi-novel-combination: ${recentTaskKeys.length} novel tool-permission combinations within last 24h.`
23007
+ ),
23008
+ details: {
23009
+ layer: "C",
23010
+ novel_combinations: recentTaskKeys
23011
+ },
23012
+ observed_at: now.toISOString(),
23013
+ evidence_audit_ids: [],
23014
+ fortress_id: ""
23015
+ };
23016
+ findings.push(aggregate);
23017
+ }
23018
+ return findings;
21244
23019
  }
21245
- /**
21246
- * Drop expired payload bundles. Returns the count of bundles pruned.
21247
- * Caller wires this into the cocoon-unlock initialization path.
21248
- */
21249
- async pruneExpired(now) {
21250
- const cutoff = (now ?? /* @__PURE__ */ new Date()).toISOString();
21251
- const entries = await this.storage.list(
21252
- AGGREGATOR_PAYLOAD_NAMESPACE,
21253
- AGGREGATOR_PAYLOAD_KEY_PREFIX
21254
- );
21255
- let pruned = 0;
21256
- for (const meta of entries) {
21257
- const aggregatorId = stripKeyPrefix(meta.key);
21258
- if (aggregatorId === null) continue;
21259
- const raw = await this.storage.read(
21260
- AGGREGATOR_PAYLOAD_NAMESPACE,
21261
- meta.key
21262
- );
21263
- if (!raw) continue;
23020
+ buildLayerCFinding(task, key, severity, now) {
23021
+ return {
23022
+ finding_id: "",
23023
+ sentinel_id: this.sentinelId,
23024
+ severity,
23025
+ summary: truncateSummary2(
23026
+ `novel-permission-combination: tools=[${task.tools.join(",")}] observed in single task burst (${task.tools.length} distinct tools).`
23027
+ ),
23028
+ details: {
23029
+ layer: "C",
23030
+ combination_key: key,
23031
+ tools: task.tools,
23032
+ task_started_at: new Date(task.startTs).toISOString()
23033
+ },
23034
+ observed_at: now.toISOString(),
23035
+ evidence_audit_ids: [],
23036
+ fortress_id: ""
23037
+ };
23038
+ }
23039
+ // ── LLM-assist ────────────────────────────────────────────────────
23040
+ classifyHandle(ctx) {
23041
+ const selector = ctx.substrateSelector;
23042
+ if (!selector) return null;
23043
+ const fn = selector.invokeClassify;
23044
+ if (typeof fn !== "function") return null;
23045
+ return async (items) => {
21264
23046
  try {
21265
- const envelope = JSON.parse(bytesToString(raw));
21266
- const aad = stringToBytes(aggregatorId);
21267
- const plaintext = decrypt(envelope, this.encryptionKey, aad);
21268
- const parsed = JSON.parse(
21269
- bytesToString(plaintext)
21270
- );
21271
- if (parsed.retention_until <= cutoff) {
21272
- await this.storage.delete(AGGREGATOR_PAYLOAD_NAMESPACE, meta.key);
21273
- pruned += 1;
23047
+ const resp = await fn.call(selector, "sentinel-scoring", {
23048
+ kind: "classify",
23049
+ items,
23050
+ categories: ["benign", "suspicious"]
23051
+ });
23052
+ if (resp.failureClass) return { kind: "failure", message: "substrate failure" };
23053
+ if (resp.body.kind === "classify") {
23054
+ return { kind: "classify", results: resp.body.results };
21274
23055
  }
23056
+ return { kind: "failure", message: resp.body.message };
21275
23057
  } catch {
23058
+ return null;
23059
+ }
23060
+ };
23061
+ }
23062
+ async consultClassifier(classify, obs) {
23063
+ const item = JSON.stringify({
23064
+ tool: obs.tool,
23065
+ proxy: obs.proxy,
23066
+ args_summary: obs.argsSummary
23067
+ });
23068
+ const result = await classify([item]);
23069
+ if (!result || result.kind !== "classify") return "unknown";
23070
+ const top = result.results[0];
23071
+ if (!top) return "unknown";
23072
+ if (top.category === "suspicious" && top.confidence >= 0.5) {
23073
+ return "suspicious";
23074
+ }
23075
+ if (top.category === "benign") return "benign";
23076
+ return "unknown";
23077
+ }
23078
+ // ── helpers ───────────────────────────────────────────────────────
23079
+ observationFromEntry(entry) {
23080
+ const op = entry.operation;
23081
+ let tool = null;
23082
+ let proxy = false;
23083
+ for (const prefix of GATE_PREFIXES) {
23084
+ if (op.startsWith(prefix)) {
23085
+ tool = op.slice(prefix.length);
23086
+ proxy = prefix === "gate_allow_proxy:";
23087
+ break;
21276
23088
  }
21277
23089
  }
21278
- return { pruned };
23090
+ if (!tool) return null;
23091
+ const ts = Date.parse(entry.timestamp);
23092
+ if (!Number.isFinite(ts)) return null;
23093
+ const argsSummary = extractArgsSummary(entry.details);
23094
+ return { tool, proxy, ts, entry, argsSummary };
21279
23095
  }
21280
23096
  };
21281
23097
  }
21282
23098
  });
21283
23099
 
23100
+ // src/sentinel/sentinels/index.ts
23101
+ var PHI1_BASELINE_CATALOG;
23102
+ var init_sentinels = __esm({
23103
+ "src/sentinel/sentinels/index.ts"() {
23104
+ init_egress_volume_watcher();
23105
+ init_cross_agent_chatter_watcher();
23106
+ init_credential_usage_watcher();
23107
+ init_suspicious_tool_call_detector();
23108
+ PHI1_BASELINE_CATALOG = [
23109
+ {
23110
+ sentinelId: EGRESS_VOLUME_SENTINEL_ID,
23111
+ description: "Watches outbound proxy-call volume per upstream server and surfaces anomalous spikes against a rolling 7-day baseline.",
23112
+ factory: () => new EgressVolumeWatcher()
23113
+ },
23114
+ {
23115
+ sentinelId: CROSS_AGENT_CHATTER_SENTINEL_ID,
23116
+ description: "Watches inter-agent communication patterns. Surfaces per-pair rate spikes (3 or 6 sigma over the rolling 7-day baseline) and new-partner appearances. Escalates to alert when one source agent picks up 3 or more new partners in 24h.",
23117
+ factory: () => new CrossAgentChatterWatcher()
23118
+ },
23119
+ {
23120
+ sentinelId: CREDENTIAL_USAGE_SENTINEL_ID,
23121
+ description: "Watches per-agent credential reads. Surfaces (agent, secret) usage that exceeds a rolling 7-day baseline, and unfamiliar secret combinations the agent uses for the first time in one 24h window.",
23122
+ factory: () => new CredentialUsageWatcher()
23123
+ },
23124
+ {
23125
+ sentinelId: SUSPICIOUS_TOOL_CALL_SENTINEL_ID,
23126
+ description: "Surfaces tool calls whose argument shape, call frequency, or permission combination looks unusual for the fortress's recent history.",
23127
+ factory: () => new SuspiciousToolCallDetector()
23128
+ }
23129
+ ];
23130
+ }
23131
+ });
23132
+ function sentinelSubscriptionsPath(storagePath) {
23133
+ return join(storagePath, "sentinel-subscriptions.json");
23134
+ }
23135
+ async function loadSentinelSubscriptions(storagePath) {
23136
+ const filePath = sentinelSubscriptionsPath(storagePath);
23137
+ try {
23138
+ const raw = await readFile(filePath, "utf8");
23139
+ const parsed = JSON.parse(raw);
23140
+ if (parsed.version !== FILE_VERSION) return /* @__PURE__ */ new Set();
23141
+ if (!Array.isArray(parsed.subscribed)) return /* @__PURE__ */ new Set();
23142
+ const cleaned = parsed.subscribed.filter(
23143
+ (id) => typeof id === "string" && id.length > 0
23144
+ );
23145
+ return new Set(cleaned);
23146
+ } catch {
23147
+ return /* @__PURE__ */ new Set();
23148
+ }
23149
+ }
23150
+ async function saveSentinelSubscriptions(storagePath, subscribed) {
23151
+ const filePath = sentinelSubscriptionsPath(storagePath);
23152
+ await mkdir(dirname(filePath), { recursive: true });
23153
+ const payload = {
23154
+ version: FILE_VERSION,
23155
+ subscribed: [...new Set(subscribed)].filter((s) => s.length > 0).sort()
23156
+ };
23157
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}
23158
+ `, {
23159
+ mode: 384
23160
+ });
23161
+ }
23162
+ var FILE_VERSION;
23163
+ var init_subscription_store = __esm({
23164
+ "src/sentinel/subscription-store.ts"() {
23165
+ FILE_VERSION = 1;
23166
+ }
23167
+ });
23168
+
21284
23169
  // src/principal-policy/tools.ts
21285
23170
  function createPrincipalPolicyTools(policy, baseline, auditLog) {
21286
23171
  return [
@@ -32758,7 +34643,7 @@ var init_recovery_key_disclosure = __esm({
32758
34643
  });
32759
34644
 
32760
34645
  // src/hub/types.ts
32761
- var init_types3 = __esm({
34646
+ var init_types4 = __esm({
32762
34647
  "src/hub/types.ts"() {
32763
34648
  }
32764
34649
  });
@@ -33797,7 +35682,7 @@ var init_hub = __esm({
33797
35682
  "src/hub/index.ts"() {
33798
35683
  init_constants3();
33799
35684
  init_errors4();
33800
- init_types3();
35685
+ init_types4();
33801
35686
  init_agent_registry();
33802
35687
  init_inbox_store();
33803
35688
  init_inbox_aggregator();
@@ -33906,7 +35791,17 @@ var init_operator_chat_audit_events = __esm({
33906
35791
  * fold. The concierge omits that category and continues; the user-
33907
35792
  * facing query is never broken. Body carries category + failure_reason.
33908
35793
  */
33909
- CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed"
35794
+ CONCIERGE_CONTEXT_FETCHER_FAILED: "operator_concierge_context_fetcher_failed",
35795
+ /**
35796
+ * Concierge surfaced a proactive starter when a fresh conversation
35797
+ * thread opened (WP-V1.3-9 Tau-5). Emitted once per starter, never on
35798
+ * follow-up turns within the same thread. Body carries `thread_id`,
35799
+ * `trigger` (stable enum), and `triggered_agents_count`. The starter
35800
+ * text body is NOT carried; the trigger enum is sufficient for
35801
+ * dashboard grouping and keeps fortress-internal agent ids off the
35802
+ * audit surface.
35803
+ */
35804
+ CONCIERGE_PROACTIVE_SUGGESTION_OFFERED: "operator_concierge_proactive_suggestion_offered"
33910
35805
  };
33911
35806
  }
33912
35807
  });
@@ -34740,9 +36635,108 @@ var init_concierge_query_grammar = __esm({
34740
36635
  LLM_ASSIST_THRESHOLD = 0.5;
34741
36636
  }
34742
36637
  });
36638
+
36639
+ // src/chat/agent-context-cache.ts
34743
36640
  function approxTokenLen2(text) {
34744
36641
  return Math.ceil(text.length / 4);
34745
36642
  }
36643
+ function formatSnapshotLine(snapshot) {
36644
+ const flagLabel = snapshot.state_flags.join("+") || "no_flags";
36645
+ const work = snapshot.current_work_summary ? `, last: ${snapshot.current_work_summary}` : "";
36646
+ const verascore = snapshot.recent_verascore_delta_24h !== null ? `, verascore \u0394${snapshot.recent_verascore_delta_24h.toFixed(2)}` : "";
36647
+ return `- ${snapshot.agent_name} (template: ${snapshot.template}): ${flagLabel}, ${snapshot.recent_audit_count_24h} audit/24h, ${snapshot.recent_concordia_receipts_count_24h} receipts${verascore}${work}`;
36648
+ }
36649
+ function urgencyRank(snapshot) {
36650
+ for (let i = 0; i < STATE_FLAG_ORDER.length; i++) {
36651
+ if (snapshot.state_flags.includes(STATE_FLAG_ORDER[i])) {
36652
+ return i;
36653
+ }
36654
+ }
36655
+ return STATE_FLAG_ORDER.length;
36656
+ }
36657
+ function formatCurrentAgentStateSection(snapshots, opts) {
36658
+ if (snapshots.length === 0) return "";
36659
+ const budget = opts?.maxTokens ?? DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
36660
+ const sorted = [...snapshots].sort(
36661
+ (a, b) => urgencyRank(a) - urgencyRank(b)
36662
+ );
36663
+ const headerTokens = approxTokenLen2(`${SECTION_HEADER}
36664
+ `);
36665
+ const sepTokens = approxTokenLen2("\n");
36666
+ let runningTokens = headerTokens;
36667
+ const kept = [];
36668
+ for (const snap of sorted) {
36669
+ const line = formatSnapshotLine(snap);
36670
+ const tokens = approxTokenLen2(line) + (kept.length > 0 ? sepTokens : 0);
36671
+ if (kept.length === 0) {
36672
+ kept.push(line);
36673
+ runningTokens += tokens;
36674
+ continue;
36675
+ }
36676
+ if (runningTokens + tokens > budget) break;
36677
+ kept.push(line);
36678
+ runningTokens += tokens;
36679
+ }
36680
+ return `${SECTION_HEADER}
36681
+ ${kept.join("\n")}`;
36682
+ }
36683
+ function generateProactiveStarter(snapshots) {
36684
+ if (snapshots.length === 0) return null;
36685
+ const stuck = snapshots.filter((s) => s.state_flags.includes("stuck"));
36686
+ if (stuck.length > 0) {
36687
+ const first = stuck[0];
36688
+ if (first === void 0) return null;
36689
+ const last = first.current_work_summary ? ` (last: ${first.current_work_summary})` : "";
36690
+ return {
36691
+ text: `Your ${first.agent_name} agent looks stuck${last}. Should I check its session state?`,
36692
+ trigger: "stuck_agent",
36693
+ triggered_agents_count: stuck.length
36694
+ };
36695
+ }
36696
+ const pending = snapshots.filter(
36697
+ (s) => s.state_flags.includes("has_pending_approvals")
36698
+ );
36699
+ if (pending.length > 0) {
36700
+ const names = pending.slice(0, 3).map((s) => s.agent_name).join(", ");
36701
+ return {
36702
+ text: `You have pending approvals across ${names}. Want to walk through them?`,
36703
+ trigger: "pending_approvals",
36704
+ triggered_agents_count: pending.length
36705
+ };
36706
+ }
36707
+ const findings = snapshots.filter(
36708
+ (s) => s.state_flags.includes("has_open_findings")
36709
+ );
36710
+ if (findings.length > 0) {
36711
+ return {
36712
+ text: `Sentinel has open findings on ${findings.length} ${findings.length === 1 ? "agent" : "agents"}. Want a summary?`,
36713
+ trigger: "open_findings",
36714
+ triggered_agents_count: findings.length
36715
+ };
36716
+ }
36717
+ return {
36718
+ text: "Your fortress is quiet. Anything you'd like to inspect?",
36719
+ trigger: "all_idle",
36720
+ triggered_agents_count: snapshots.length
36721
+ };
36722
+ }
36723
+ var STATE_FLAG_ORDER, SECTION_HEADER, DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET;
36724
+ var init_agent_context_cache = __esm({
36725
+ "src/chat/agent-context-cache.ts"() {
36726
+ STATE_FLAG_ORDER = [
36727
+ "stuck",
36728
+ "has_pending_approvals",
36729
+ "has_open_findings",
36730
+ "active",
36731
+ "idle"
36732
+ ];
36733
+ SECTION_HEADER = "## Current agent state";
36734
+ DEFAULT_AGENT_CONTEXT_TOKEN_BUDGET = 400;
36735
+ }
36736
+ });
36737
+ function approxTokenLen3(text) {
36738
+ return Math.ceil(text.length / 4);
36739
+ }
34746
36740
  function makeEventId(prefix) {
34747
36741
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}`;
34748
36742
  }
@@ -34764,7 +36758,7 @@ function formatPriorTurnLine(turn) {
34764
36758
  function hashOf(input) {
34765
36759
  return hashToString(sha256(stringToBytes(input)));
34766
36760
  }
34767
- var DEFAULT_CONCIERGE_MAX_TOKENS, DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS, DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS, DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET, DEFAULT_CONCIERGE_SESSION_TTL_MS, DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
36761
+ var DEFAULT_CONCIERGE_MAX_TOKENS, DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS, DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS, DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET, DEFAULT_CONCIERGE_SESSION_TTL_MS, DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET, DEFAULT_CONCIERGE_AGENT_STATE_BUDGET, SANCTUARY_DOMAIN_REFERENCE, OperatorChatService;
34768
36762
  var init_operator_chat_service = __esm({
34769
36763
  "src/chat/operator-chat-service.ts"() {
34770
36764
  init_hashing();
@@ -34773,12 +36767,14 @@ var init_operator_chat_service = __esm({
34773
36767
  init_operator_chat_types();
34774
36768
  init_concierge_context_router();
34775
36769
  init_concierge_query_grammar();
36770
+ init_agent_context_cache();
34776
36771
  DEFAULT_CONCIERGE_MAX_TOKENS = 512;
34777
36772
  DEFAULT_CONCIERGE_HISTORY_WINDOW_TURNS = 10;
34778
36773
  DEFAULT_CONCIERGE_HISTORY_FRESHNESS_MS = 24 * 60 * 60 * 1e3;
34779
36774
  DEFAULT_CONCIERGE_HISTORY_TOKEN_BUDGET = 500;
34780
36775
  DEFAULT_CONCIERGE_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
34781
36776
  DEFAULT_CONCIERGE_DYNAMIC_CONTEXT_BUDGET = 2e3;
36777
+ DEFAULT_CONCIERGE_AGENT_STATE_BUDGET = 400;
34782
36778
  SANCTUARY_DOMAIN_REFERENCE = `Castle Architecture (four enforcement layers):
34783
36779
  1. Castle Wall: OS-boundary egress filter enforced at the kernel level. Blocks unauthorized outbound calls even from prompt-injected agents.
34784
36780
  2. Sentinels: internal observation via process introspection. Surfaces anomalies to the operator; does not enforce.
@@ -34825,6 +36821,15 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34825
36821
  dynamicContextBudget;
34826
36822
  agentRegistry;
34827
36823
  grammarLlmAssist;
36824
+ agentContextCache;
36825
+ agentStateBudget;
36826
+ /**
36827
+ * Per-thread guard so the proactive starter fires at most once per
36828
+ * fresh thread. Tracks the thread_id the starter was last offered
36829
+ * for; subsequent `getProactiveStarter()` calls within the same
36830
+ * thread return null instead of re-emitting.
36831
+ */
36832
+ starterOfferedForThreadId;
34828
36833
  /**
34829
36834
  * In-memory thread_id assigned to the active concierge session.
34830
36835
  * The first sendConcierge call after construction allocates a fresh
@@ -34869,6 +36874,10 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34869
36874
  if (deps.conciergeGrammarLlmAssist) {
34870
36875
  this.grammarLlmAssist = deps.conciergeGrammarLlmAssist;
34871
36876
  }
36877
+ if (deps.conciergeAgentContextCache) {
36878
+ this.agentContextCache = deps.conciergeAgentContextCache;
36879
+ }
36880
+ this.agentStateBudget = deps.conciergeAgentStateBudget !== void 0 && deps.conciergeAgentStateBudget > 0 ? deps.conciergeAgentStateBudget : DEFAULT_CONCIERGE_AGENT_STATE_BUDGET;
34872
36881
  }
34873
36882
  // ── Concierge ─────────────────────────────────────────────────────────
34874
36883
  /**
@@ -34890,6 +36899,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34890
36899
  const nowMs = this.clock();
34891
36900
  if (this.activeMemoryThreadId && this.lastInteractionAt !== void 0 && nowMs - this.lastInteractionAt > this.sessionTtlMs) {
34892
36901
  this.activeMemoryThreadId = void 0;
36902
+ this.starterOfferedForThreadId = void 0;
34893
36903
  }
34894
36904
  const operatorMessage = {
34895
36905
  message_id: randomUUID(),
@@ -34928,6 +36938,11 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34928
36938
  });
34929
36939
  }
34930
36940
  const parsedGrammar = await this.runGrammarParse(filterResult.filtered);
36941
+ const agentSnapshots = this.agentContextCache ? this.agentContextCache.read() : [];
36942
+ const agentStateSection = this.agentContextCache ? formatCurrentAgentStateSection(agentSnapshots, {
36943
+ maxTokens: this.agentStateBudget
36944
+ }) : "";
36945
+ const renderedAgentCount = agentStateSection ? agentSnapshots.length : 0;
34931
36946
  const start = Date.now();
34932
36947
  let conciergeBody;
34933
36948
  let servedBy = "disabled";
@@ -34952,7 +36967,8 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
34952
36967
  dynamicCategoriesIncluded = dynamicResult.categoriesIncluded;
34953
36968
  const context = await this.assembleConciergeContext(
34954
36969
  priorTurns,
34955
- dynamicResult.section
36970
+ dynamicResult.section,
36971
+ agentStateSection
34956
36972
  );
34957
36973
  const response = await this.substrateSelector.invokeSummarize(
34958
36974
  "concierge",
@@ -35019,7 +37035,8 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
35019
37035
  prior_turns_folded: memoryReadFailureReason === null ? priorTurns.length : 0
35020
37036
  } : {},
35021
37037
  ...this.contextFetchers ? { dynamic_context_categories: [...dynamicCategoriesIncluded] } : {},
35022
- parsed_grammar: auditSafeSummary(parsedGrammar)
37038
+ parsed_grammar: auditSafeSummary(parsedGrammar),
37039
+ ...this.agentContextCache !== void 0 ? { agent_context_snapshot_count: renderedAgentCount } : {}
35023
37040
  };
35024
37041
  this.emit(OPERATOR_CHAT_OPS.CONCIERGE_CHAT, payload, outcome === "ok" ? "success" : "failure");
35025
37042
  return {
@@ -35130,6 +37147,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
35130
37147
  if (!removed) return false;
35131
37148
  if (this.activeMemoryThreadId === threadId) {
35132
37149
  this.activeMemoryThreadId = void 0;
37150
+ this.starterOfferedForThreadId = void 0;
35133
37151
  }
35134
37152
  const payload = {
35135
37153
  version: "1.2",
@@ -35148,9 +37166,65 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
35148
37166
  * Reset the active session memory thread. Subsequent sendConcierge
35149
37167
  * calls allocate a fresh thread_id. Surfaced for tests + future "new
35150
37168
  * conversation" affordance; not currently called by the dashboard.
37169
+ *
37170
+ * Tau-5: also clears the proactive-starter guard so the next
37171
+ * `getProactiveStarter()` call against the freshly-allocated thread
37172
+ * is eligible to fire.
35151
37173
  */
35152
37174
  resetConciergeMemoryThread() {
35153
37175
  this.activeMemoryThreadId = void 0;
37176
+ this.starterOfferedForThreadId = void 0;
37177
+ }
37178
+ /**
37179
+ * WP-V1.3-9 Tau-5: surface a proactive starter for the current
37180
+ * concierge session. Intended to be called by the dashboard UI when
37181
+ * the operator opens the chat surface, before any operator typing.
37182
+ *
37183
+ * Returns null when:
37184
+ * - No agent-context cache is wired (Tau-5 disabled).
37185
+ * - No concierge memory store is wired (no thread_id namespace).
37186
+ * - The cache snapshot has no signal (empty fortress).
37187
+ * - A starter has already been offered for the active thread (the
37188
+ * guard ensures one starter per fresh thread).
37189
+ *
37190
+ * Side effects:
37191
+ * - Allocates a fresh thread_id if none is active.
37192
+ * - Emits the `operator_concierge_proactive_suggestion_offered`
37193
+ * audit event with the trigger class + triggered_agents_count.
37194
+ * - Records the offered thread_id so the next call within the same
37195
+ * thread is a no-op.
37196
+ *
37197
+ * The returned starter's `text` is operator-visible copy; the
37198
+ * dashboard renders it as a system-message-style starter the
37199
+ * operator can accept (clicks/types follow-up) or dismiss (types a
37200
+ * new query).
37201
+ */
37202
+ getProactiveStarter() {
37203
+ if (!this.agentContextCache) return null;
37204
+ if (!this.memory) return null;
37205
+ const threadId = this.ensureActiveMemoryThread();
37206
+ if (this.starterOfferedForThreadId === threadId) return null;
37207
+ const snapshots = this.agentContextCache.read();
37208
+ const starter = generateProactiveStarter(snapshots);
37209
+ if (!starter) return null;
37210
+ const payload = {
37211
+ version: "1.2",
37212
+ event_id: makeEventId("conc-starter"),
37213
+ emitted_at: (/* @__PURE__ */ new Date()).toISOString(),
37214
+ identity_id: this.identityId,
37215
+ kind: "operator_concierge_proactive_suggestion_offered",
37216
+ surface: "concierge",
37217
+ thread_id: threadId,
37218
+ trigger: starter.trigger,
37219
+ triggered_agents_count: starter.triggered_agents_count
37220
+ };
37221
+ this.emit(
37222
+ OPERATOR_CHAT_OPS.CONCIERGE_PROACTIVE_SUGGESTION_OFFERED,
37223
+ payload,
37224
+ "success"
37225
+ );
37226
+ this.starterOfferedForThreadId = threadId;
37227
+ return starter;
35154
37228
  }
35155
37229
  ensureActiveMemoryThread() {
35156
37230
  if (!this.activeMemoryThreadId) {
@@ -35195,7 +37269,7 @@ Note: this is a static reference block (v1.2.x). Dynamic context injection (live
35195
37269
  * if available; the v1.2 selector does not expose one, so structured
35196
37270
  * serialization is the canonical path for v1.3.
35197
37271
  */
35198
- async assembleConciergeContext(priorTurns = [], dynamicSection = "") {
37272
+ async assembleConciergeContext(priorTurns = [], dynamicSection = "", agentStateSection = "") {
35199
37273
  const ref = `## Sanctuary reference
35200
37274
  ${SANCTUARY_DOMAIN_REFERENCE}`;
35201
37275
  const priorSection = this.formatPriorTurnsSection(priorTurns);
@@ -35203,6 +37277,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
35203
37277
  return [
35204
37278
  ref,
35205
37279
  ...dynamicSection ? [dynamicSection] : [],
37280
+ ...agentStateSection ? [agentStateSection] : [],
35206
37281
  ...priorSection ? [priorSection] : [],
35207
37282
  "## Recent activity\n(no providers wired)",
35208
37283
  "## Wrapped agents\n(no providers wired)",
@@ -35217,6 +37292,7 @@ ${SANCTUARY_DOMAIN_REFERENCE}`;
35217
37292
  return [
35218
37293
  ref,
35219
37294
  ...dynamicSection ? [dynamicSection] : [],
37295
+ ...agentStateSection ? [agentStateSection] : [],
35220
37296
  ...priorSection ? [priorSection] : [],
35221
37297
  `## Recent activity
35222
37298
  ${activity}`,
@@ -35299,14 +37375,14 @@ ${inbox}`
35299
37375
  if (turns.length === 0) return "";
35300
37376
  const HEADER = "## Prior conversation";
35301
37377
  const lines = turns.map(formatPriorTurnLine);
35302
- const headerTokens = approxTokenLen2(`${HEADER}
37378
+ const headerTokens = approxTokenLen3(`${HEADER}
35303
37379
  `);
35304
- const sepTokens = approxTokenLen2("\n");
37380
+ const sepTokens = approxTokenLen3("\n");
35305
37381
  let runningTokens = headerTokens;
35306
37382
  let runningLines = [];
35307
37383
  for (let i = lines.length - 1; i >= 0; i--) {
35308
37384
  const line = lines[i];
35309
- const tokens = approxTokenLen2(line) + (runningLines.length > 0 ? sepTokens : 0);
37385
+ const tokens = approxTokenLen3(line) + (runningLines.length > 0 ? sepTokens : 0);
35310
37386
  if (runningTokens + tokens > this.historyTokenBudget) break;
35311
37387
  runningTokens += tokens;
35312
37388
  runningLines.push(line);
@@ -35334,7 +37410,7 @@ ${runningLines.join("\n")}`;
35334
37410
  function chatStorageKey(surface, threadKey) {
35335
37411
  return `${surface}.${threadKey}`;
35336
37412
  }
35337
- var OPERATOR_CHAT_NAMESPACE, HKDF_INFO2, OperatorChatStore;
37413
+ var OPERATOR_CHAT_NAMESPACE, HKDF_INFO3, OperatorChatStore;
35338
37414
  var init_operator_chat_store = __esm({
35339
37415
  "src/chat/operator-chat-store.ts"() {
35340
37416
  init_encryption();
@@ -35342,13 +37418,13 @@ var init_operator_chat_store = __esm({
35342
37418
  init_encoding();
35343
37419
  init_operator_chat_types();
35344
37420
  OPERATOR_CHAT_NAMESPACE = "_chat";
35345
- HKDF_INFO2 = "operator-chat-store-v1";
37421
+ HKDF_INFO3 = "operator-chat-store-v1";
35346
37422
  OperatorChatStore = class {
35347
37423
  storage;
35348
37424
  encryptionKey;
35349
37425
  constructor(storage, masterKey) {
35350
37426
  this.storage = storage;
35351
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO2);
37427
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO3);
35352
37428
  }
35353
37429
  /**
35354
37430
  * Load a thread. Returns null if no record exists or if the on-disk
@@ -35433,7 +37509,7 @@ var init_operator_chat_store = __esm({
35433
37509
  function bundleKey(threadId) {
35434
37510
  return `${CONCIERGE_MEMORY_KEY_PREFIX}${threadId}`;
35435
37511
  }
35436
- function stripKeyPrefix2(key) {
37512
+ function stripKeyPrefix3(key) {
35437
37513
  if (!key.startsWith(CONCIERGE_MEMORY_KEY_PREFIX)) return null;
35438
37514
  return key.slice(CONCIERGE_MEMORY_KEY_PREFIX.length);
35439
37515
  }
@@ -35444,7 +37520,7 @@ function lastTurnId(bundle) {
35444
37520
  }
35445
37521
  return max;
35446
37522
  }
35447
- var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO3, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
37523
+ var CONCIERGE_MEMORY_NAMESPACE, CONCIERGE_MEMORY_KEY_PREFIX, HKDF_INFO4, DEFAULT_CONCIERGE_RETENTION_DAYS, MAX_BUNDLE_BYTES3, ConciergeMemoryStore;
35448
37524
  var init_concierge_memory_store = __esm({
35449
37525
  "src/chat/concierge-memory-store.ts"() {
35450
37526
  init_encryption();
@@ -35452,7 +37528,7 @@ var init_concierge_memory_store = __esm({
35452
37528
  init_encoding();
35453
37529
  CONCIERGE_MEMORY_NAMESPACE = "_chat";
35454
37530
  CONCIERGE_MEMORY_KEY_PREFIX = "concierge_memory.";
35455
- HKDF_INFO3 = "concierge-memory-store-v1";
37531
+ HKDF_INFO4 = "concierge-memory-store-v1";
35456
37532
  DEFAULT_CONCIERGE_RETENTION_DAYS = 30;
35457
37533
  MAX_BUNDLE_BYTES3 = 4 * 1024 * 1024;
35458
37534
  ConciergeMemoryStore = class {
@@ -35463,7 +37539,7 @@ var init_concierge_memory_store = __esm({
35463
37539
  locks;
35464
37540
  constructor(opts) {
35465
37541
  this.storage = opts.storage;
35466
- this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO3);
37542
+ this.encryptionKey = derivePurposeKey(opts.masterKey, HKDF_INFO4);
35467
37543
  this.fortressId = opts.fortressId;
35468
37544
  this.retentionDays = opts.retentionDays !== void 0 && opts.retentionDays > 0 ? opts.retentionDays : DEFAULT_CONCIERGE_RETENTION_DAYS;
35469
37545
  this.locks = /* @__PURE__ */ new Map();
@@ -35589,7 +37665,7 @@ var init_concierge_memory_store = __esm({
35589
37665
  );
35590
37666
  const summaries = [];
35591
37667
  for (const meta of entries) {
35592
- const threadId = stripKeyPrefix2(meta.key);
37668
+ const threadId = stripKeyPrefix3(meta.key);
35593
37669
  if (threadId === null) continue;
35594
37670
  const bundle = await this.loadBundle(threadId);
35595
37671
  if (!bundle || bundle.turns.length === 0) continue;
@@ -35642,7 +37718,7 @@ var init_concierge_memory_store = __esm({
35642
37718
  );
35643
37719
  let pruned = 0;
35644
37720
  for (const meta of entries) {
35645
- const threadId = stripKeyPrefix2(meta.key);
37721
+ const threadId = stripKeyPrefix3(meta.key);
35646
37722
  if (threadId === null) continue;
35647
37723
  pruned += await this.withLock(threadId, async () => {
35648
37724
  const bundle = await this.loadBundle(threadId);
@@ -36112,7 +38188,7 @@ var init_defaults = __esm({
36112
38188
  });
36113
38189
 
36114
38190
  // src/intelligence/policy-store.ts
36115
- var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO4, IntelligenceConfigStore;
38191
+ var INTELLIGENCE_NAMESPACE, SUBSTRATE_CONFIG_KEY, HKDF_INFO5, IntelligenceConfigStore;
36116
38192
  var init_policy_store = __esm({
36117
38193
  "src/intelligence/policy-store.ts"() {
36118
38194
  init_encryption();
@@ -36121,13 +38197,13 @@ var init_policy_store = __esm({
36121
38197
  init_defaults();
36122
38198
  INTELLIGENCE_NAMESPACE = "_intelligence";
36123
38199
  SUBSTRATE_CONFIG_KEY = "substrate-config";
36124
- HKDF_INFO4 = "intelligence-substrate-config";
38200
+ HKDF_INFO5 = "intelligence-substrate-config";
36125
38201
  IntelligenceConfigStore = class {
36126
38202
  storage;
36127
38203
  encryptionKey;
36128
38204
  constructor(storage, masterKey) {
36129
38205
  this.storage = storage;
36130
- this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO4);
38206
+ this.encryptionKey = derivePurposeKey(masterKey, HKDF_INFO5);
36131
38207
  }
36132
38208
  /**
36133
38209
  * Load the operator's substrate config from disk. Returns the config
@@ -40078,6 +42154,38 @@ ${err.message}
40078
42154
  if (dashboard) {
40079
42155
  dashboard.setApprovalAggregator(approvalAggregator);
40080
42156
  }
42157
+ const sentinelFindingStore = new SentinelFindingStore({
42158
+ storage,
42159
+ masterKey,
42160
+ fortressId: fortressIdForAggregator
42161
+ });
42162
+ const sentinelRegistry = new SentinelRegistry();
42163
+ for (const entry of PHI1_BASELINE_CATALOG) {
42164
+ sentinelRegistry.register(entry);
42165
+ }
42166
+ const sentinelDispatcher = new SentinelDispatcher({
42167
+ registry: sentinelRegistry,
42168
+ findingStore: sentinelFindingStore,
42169
+ auditLog,
42170
+ fortressId: fortressIdForAggregator,
42171
+ identityId: aggregatorIdentityId
42172
+ });
42173
+ try {
42174
+ const persistedSubscriptions = await loadSentinelSubscriptions(
42175
+ config.storage_path
42176
+ );
42177
+ for (const sentinelId of persistedSubscriptions) {
42178
+ try {
42179
+ await sentinelDispatcher.subscribeSentinel(sentinelId);
42180
+ } catch {
42181
+ }
42182
+ }
42183
+ } catch {
42184
+ }
42185
+ sentinelDispatcher.start();
42186
+ if (dashboard) {
42187
+ dashboard.setSentinelDispatcher(sentinelDispatcher);
42188
+ }
40081
42189
  const policyTools = createPrincipalPolicyTools(policy, baseline, auditLog);
40082
42190
  const { tools: sanctuaryMetaTools } = createSanctuaryTools({
40083
42191
  config,
@@ -40271,6 +42379,11 @@ var init_src = __esm({
40271
42379
  init_approval_aggregator();
40272
42380
  init_aggregator_backed_channel();
40273
42381
  init_aggregator_store();
42382
+ init_sentinel_finding_store();
42383
+ init_sentinel_registry();
42384
+ init_sentinel_dispatcher();
42385
+ init_sentinels();
42386
+ init_subscription_store();
40274
42387
  init_tools4();
40275
42388
  init_router();
40276
42389
  init_router();
@@ -45440,6 +47553,232 @@ var init_intelligence = __esm({
45440
47553
  }
45441
47554
  });
45442
47555
 
47556
+ // src/cli/sentinel.ts
47557
+ var sentinel_exports = {};
47558
+ __export(sentinel_exports, {
47559
+ runSentinelCommand: () => runSentinelCommand
47560
+ });
47561
+ async function runSentinelCommand(args) {
47562
+ const out = args.out ?? process.stdout;
47563
+ const err = args.err ?? process.stderr;
47564
+ const [sub, ...rest] = args.argv;
47565
+ if (!sub || sub === "--help" || sub === "-h") {
47566
+ printUsage6(out);
47567
+ return 0;
47568
+ }
47569
+ try {
47570
+ switch (sub) {
47571
+ case "list":
47572
+ return cmdList4(out);
47573
+ case "list-subscribed":
47574
+ return await cmdListSubscribed(rest, { out, err, args });
47575
+ case "subscribe":
47576
+ return await cmdSubscribe(rest, { out, err, args });
47577
+ case "unsubscribe":
47578
+ return await cmdUnsubscribe(rest, { out, err, args });
47579
+ case "findings":
47580
+ return await cmdFindings(rest, { out, err, args });
47581
+ default:
47582
+ err.write(`Unknown subcommand: ${sub}
47583
+ `);
47584
+ printUsage6(err);
47585
+ return 2;
47586
+ }
47587
+ } catch (e) {
47588
+ const msg = e instanceof Error ? e.message : String(e);
47589
+ err.write(`sanctuary sentinel: ${msg}
47590
+ `);
47591
+ return 1;
47592
+ }
47593
+ }
47594
+ function printUsage6(s) {
47595
+ s.write(`Usage: sanctuary sentinel <command> [args]
47596
+
47597
+ list Show the Phi-1 catalog of available
47598
+ sentinels (egress-volume only at v1.3
47599
+ Phi-1; more land in Phi-2 ... Phi-5).
47600
+ list-subscribed Show which sentinels this fortress has
47601
+ opted into. Loads from
47602
+ <storage>/sentinel-subscriptions.json.
47603
+ subscribe <sentinel-id> Opt in. Writes the subscription file.
47604
+ The server picks it up on next boot.
47605
+ unsubscribe <sentinel-id> Opt out.
47606
+ findings [opts] Read recent findings. Decrypts the
47607
+ sentinel findings store (uses the
47608
+ same passphrase as the cocoon master
47609
+ key).
47610
+ --since <iso> Filter observed_at >= iso.
47611
+ --severity <info|warn|alert> Filter by severity.
47612
+ --sentinel-id <id> Filter by emitting sentinel.
47613
+ --agent-id <id> Filter by agent attribution.
47614
+ --limit <n> Cap result count (default 100).
47615
+ `);
47616
+ }
47617
+ function cmdList4(out) {
47618
+ for (const entry of PHI1_BASELINE_CATALOG) {
47619
+ out.write(`${entry.sentinelId}
47620
+ ${entry.description}
47621
+ `);
47622
+ }
47623
+ if (PHI1_BASELINE_CATALOG.length === 0) {
47624
+ out.write("(no sentinels registered)\n");
47625
+ }
47626
+ return 0;
47627
+ }
47628
+ async function cmdListSubscribed(argv, ctx) {
47629
+ const storagePath = await resolveStoragePath2(ctx.args);
47630
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47631
+ if (subscribed.size === 0) {
47632
+ ctx.out.write("(no subscriptions)\n");
47633
+ return 0;
47634
+ }
47635
+ for (const id of [...subscribed].sort()) {
47636
+ ctx.out.write(`${id}
47637
+ `);
47638
+ }
47639
+ return 0;
47640
+ }
47641
+ async function cmdSubscribe(argv, ctx) {
47642
+ const sentinelId = argv[0];
47643
+ if (!sentinelId) {
47644
+ ctx.err.write("subscribe requires a sentinel-id\n");
47645
+ return 2;
47646
+ }
47647
+ const known = PHI1_BASELINE_CATALOG.find(
47648
+ (entry) => entry.sentinelId === sentinelId
47649
+ );
47650
+ if (!known) {
47651
+ ctx.err.write(`Unknown sentinel: ${sentinelId}
47652
+ `);
47653
+ return 2;
47654
+ }
47655
+ const storagePath = await resolveStoragePath2(ctx.args);
47656
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47657
+ if (subscribed.has(sentinelId)) {
47658
+ ctx.out.write(`Already subscribed: ${sentinelId}
47659
+ `);
47660
+ return 0;
47661
+ }
47662
+ subscribed.add(sentinelId);
47663
+ await saveSentinelSubscriptions(storagePath, subscribed);
47664
+ ctx.out.write(
47665
+ `Subscribed: ${sentinelId}
47666
+ Restart Sanctuary or wait for the next dispatcher tick to begin evaluation.
47667
+ `
47668
+ );
47669
+ return 0;
47670
+ }
47671
+ async function cmdUnsubscribe(argv, ctx) {
47672
+ const sentinelId = argv[0];
47673
+ if (!sentinelId) {
47674
+ ctx.err.write("unsubscribe requires a sentinel-id\n");
47675
+ return 2;
47676
+ }
47677
+ const storagePath = await resolveStoragePath2(ctx.args);
47678
+ const subscribed = await loadSentinelSubscriptions(storagePath);
47679
+ if (!subscribed.has(sentinelId)) {
47680
+ ctx.out.write(`Not subscribed: ${sentinelId}
47681
+ `);
47682
+ return 0;
47683
+ }
47684
+ subscribed.delete(sentinelId);
47685
+ await saveSentinelSubscriptions(storagePath, subscribed);
47686
+ ctx.out.write(`Unsubscribed: ${sentinelId}
47687
+ `);
47688
+ return 0;
47689
+ }
47690
+ async function cmdFindings(argv, ctx) {
47691
+ const filters = parseFindingFilters(argv);
47692
+ const storagePath = await resolveStoragePath2(ctx.args);
47693
+ const storage = new FilesystemStorage(`${storagePath}/state`);
47694
+ let passphrase = ctx.args.passphrase ?? process.env["SANCTUARY_PASSPHRASE"];
47695
+ if (!passphrase) {
47696
+ const resolved = await getOrCreatePassphrase();
47697
+ passphrase = resolved.value;
47698
+ }
47699
+ let existingParams;
47700
+ try {
47701
+ const raw = await storage.read("_meta", "key-params");
47702
+ if (raw) existingParams = JSON.parse(bytesToString(raw));
47703
+ } catch {
47704
+ }
47705
+ const { key: masterKey, params } = await deriveMasterKey(
47706
+ passphrase,
47707
+ existingParams
47708
+ );
47709
+ if (!existingParams) {
47710
+ await storage.write(
47711
+ "_meta",
47712
+ "key-params",
47713
+ stringToBytes(JSON.stringify(params))
47714
+ );
47715
+ }
47716
+ const fortressId = fortressIdFromStoragePath(storagePath);
47717
+ const store = new SentinelFindingStore({
47718
+ storage,
47719
+ masterKey,
47720
+ fortressId
47721
+ });
47722
+ const findings = await store.listFindings({
47723
+ limit: filters.limit ?? 100,
47724
+ ...filters.since !== void 0 ? { since: filters.since } : {},
47725
+ ...filters.severity !== void 0 ? { severity: filters.severity } : {},
47726
+ ...filters.sentinelId !== void 0 ? { sentinelId: filters.sentinelId } : {},
47727
+ ...filters.agentId !== void 0 ? { agentId: filters.agentId } : {}
47728
+ });
47729
+ if (findings.length === 0) {
47730
+ ctx.out.write("(no findings)\n");
47731
+ return 0;
47732
+ }
47733
+ for (const finding of findings) {
47734
+ ctx.out.write(
47735
+ `[${finding.observed_at}] ${finding.severity.toUpperCase()} ${finding.sentinel_id}${finding.agent_id ? ` (agent ${finding.agent_id})` : ""}: ${finding.summary}
47736
+ `
47737
+ );
47738
+ }
47739
+ return 0;
47740
+ }
47741
+ function parseFindingFilters(argv) {
47742
+ const filters = {};
47743
+ for (let i = 0; i < argv.length; i += 1) {
47744
+ const arg = argv[i];
47745
+ if (arg === "--since" && argv[i + 1]) {
47746
+ filters.since = argv[++i];
47747
+ } else if (arg === "--severity" && argv[i + 1]) {
47748
+ const next = argv[++i];
47749
+ if (next === "info" || next === "warn" || next === "alert") {
47750
+ filters.severity = next;
47751
+ }
47752
+ } else if (arg === "--sentinel-id" && argv[i + 1]) {
47753
+ filters.sentinelId = argv[++i];
47754
+ } else if (arg === "--agent-id" && argv[i + 1]) {
47755
+ filters.agentId = argv[++i];
47756
+ } else if (arg === "--limit" && argv[i + 1]) {
47757
+ const n = Number.parseInt(argv[++i], 10);
47758
+ if (!Number.isNaN(n) && n > 0) filters.limit = n;
47759
+ }
47760
+ }
47761
+ return filters;
47762
+ }
47763
+ async function resolveStoragePath2(args) {
47764
+ if (args.storagePath) return args.storagePath;
47765
+ const config = await loadConfig();
47766
+ return config.storage_path;
47767
+ }
47768
+ var init_sentinel2 = __esm({
47769
+ "src/cli/sentinel.ts"() {
47770
+ init_config();
47771
+ init_filesystem();
47772
+ init_key_derivation();
47773
+ init_encoding();
47774
+ init_passphrase();
47775
+ init_wiring();
47776
+ init_sentinel_finding_store();
47777
+ init_subscription_store();
47778
+ init_sentinels();
47779
+ }
47780
+ });
47781
+
45443
47782
  // src/mcp/broker-server.ts
45444
47783
  var broker_server_exports = {};
45445
47784
  __export(broker_server_exports, {
@@ -45535,7 +47874,7 @@ function createBrokerMcpServer(broker, opts) {
45535
47874
  case "broker/request_token": {
45536
47875
  const skill = requireString(args, "skill");
45537
47876
  const secret = requireString(args, "secret");
45538
- const scopeRaw = optionalString(args, "scope");
47877
+ const scopeRaw = optionalString2(args, "scope");
45539
47878
  const scope = scopeRaw === "rotate" ? "rotate" : scopeRaw === "read" ? "read" : void 0;
45540
47879
  const ttl = optionalNumber(args, "ttl_seconds");
45541
47880
  const binding = await broker.issueToken({
@@ -45567,7 +47906,7 @@ function createBrokerMcpServer(broker, opts) {
45567
47906
  return ok({ grants });
45568
47907
  }
45569
47908
  case "broker/audit_query": {
45570
- const since = optionalString(args, "since");
47909
+ const since = optionalString2(args, "since");
45571
47910
  const limit = optionalNumber(args, "limit");
45572
47911
  const summary = await broker.queryAudit({ since, limit });
45573
47912
  return ok(summary);
@@ -45592,7 +47931,7 @@ function requireString(args, key) {
45592
47931
  }
45593
47932
  return v;
45594
47933
  }
45595
- function optionalString(args, key) {
47934
+ function optionalString2(args, key) {
45596
47935
  const v = args[key];
45597
47936
  return typeof v === "string" && v.length > 0 ? v : void 0;
45598
47937
  }
@@ -46299,6 +48638,11 @@ async function main() {
46299
48638
  const code = await runIntelligenceCommand2({ argv: args.slice(1) });
46300
48639
  process.exit(code);
46301
48640
  }
48641
+ if (args[0] === "sentinel") {
48642
+ const { runSentinelCommand: runSentinelCommand2 } = await Promise.resolve().then(() => (init_sentinel2(), sentinel_exports));
48643
+ const code = await runSentinelCommand2({ argv: args.slice(1) });
48644
+ process.exit(code);
48645
+ }
46302
48646
  if (args[0] === "broker-server") {
46303
48647
  const { openBroker: openBroker2 } = await Promise.resolve().then(() => (init_open(), open_exports));
46304
48648
  const { createBrokerMcpServer: createBrokerMcpServer2 } = await Promise.resolve().then(() => (init_broker_server(), broker_server_exports));