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