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