@compr/opscontext-mcp 2.0.2 → 2.1.0
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/CHANGELOG.md +43 -0
- package/dist/audit.d.ts +1 -1
- package/dist/cli.js +211 -0
- package/dist/detector.d.ts +64 -0
- package/dist/detector.js +336 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +242 -0
- package/dist/index.js +44 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to OpsContext for AI Agents (previously ContextEngine — MCP server + CLI) are documented here.
|
|
4
4
|
|
|
5
|
+
## [2.1.0] — 2026-06-23 — Phase 1: cross-surface capture + drift detector + local event ingest
|
|
6
|
+
|
|
7
|
+
The first feature release after the OpsContext rebrand. Closes the wedge the audit identified: **no other tool captures AI interactions across browser + IDE + terminal and feeds them into a tamper-evident audit log with policy enforcement**. Now we do.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`src/http-server.ts`** (LOCK `[HTTP-EVENT-INGEST]`) — local event-ingest HTTP endpoint at `http://127.0.0.1:7842`. The browser extension and the VS Code extension POST batched events here; the MCP server validates and appends them to the existing hash-chained audit log via `safeAppend()`.
|
|
11
|
+
- `POST /events` — schema-validated batched events (max 50 events, 64 KB body). Event-kind allowlist: `^(browser|vscode|cli)\.` — system kinds like `learning.save` can only come from the local writer, never the network. Auth via shared 32-byte hex secret at `~/.contextengine/extension-secret` (mode 0600), compared in constant time.
|
|
12
|
+
- `GET /health` — unauthenticated liveness probe.
|
|
13
|
+
- Bound to `127.0.0.1` ONLY — never `0.0.0.0`. LAN devices cannot inject audit events.
|
|
14
|
+
- Hot-reload of the secret on every request — `init-extension-secret --force` rotates without restarting MCP.
|
|
15
|
+
- Started automatically at MCP boot. Gracefully degrades on port conflict (`OPSCONTEXT_EVENT_PORT=<n>` to override).
|
|
16
|
+
- **`src/detector.ts`** (LOCK `[DRIFT-HEURISTICS]`) — 8-heuristic drift / loop / fabrication detector.
|
|
17
|
+
- **loop** (warn): same prompt sent 3+ times in 5 min (Jaccard > 0.6 token overlap)
|
|
18
|
+
- **stuck** (warn): identical tool call 3+ times in 5 min
|
|
19
|
+
- **context_bloat** (warn): session > 80K tokens with no `session.save` event
|
|
20
|
+
- **fabrication_suspect** (critical): assistant response cites `file.ext:NN` that doesn't exist on disk
|
|
21
|
+
- **drift** (info): per-session, last 3 prompts have joint Jaccard < 0.10 against the session's first prompt
|
|
22
|
+
- **no_insight** (info): 30+ tool calls since the last `learning.save`
|
|
23
|
+
- **silent_failure** (critical): same tool returns error 3+ times in 5 min
|
|
24
|
+
- **stale_doc_signal**: stubbed (Phase 3.1; reads `policy.json` `doc_coverage`)
|
|
25
|
+
- `watchAuditLog()` uses `fs.watch` + 250 ms debounce + in-memory LRU dedupe (100 entries) so the same signal doesn't fire every poll cycle.
|
|
26
|
+
- Auto-emits `drift.detected` audit records for each fired signal — alerting itself is auditable.
|
|
27
|
+
- **`contextengine watch`** CLI — streams alerts as they fire. Supports `--json` (NDJSON for log aggregators), `--severity info|warn|critical` (floor filter), `--once` (single-scan exit, code 2 if any critical signal — usable in CI), `--window SECONDS`.
|
|
28
|
+
- **`contextengine init-extension-secret`** CLI — generates a 32-byte hex secret at `~/.contextengine/extension-secret` (mode 0600). Refuses by default if one already exists (`--force` to rotate).
|
|
29
|
+
- **`contextengine emit-event <kind> <payload-json> [--actor NAME]`** CLI — appends a single event to the audit log. Used by the VS Code extension `0.9.0` for `vscode.prompt_submit` and `vscode.tool_call` events. Also useful for custom integrations and scripted tests.
|
|
30
|
+
- **`drift_status` MCP tool** — agents can call this between major task phases to self-check active signals. Returns "pause and surface to the human" guidance if any critical signal is active.
|
|
31
|
+
|
|
32
|
+
### Audit-log event types added (additive — no breaking changes)
|
|
33
|
+
- `browser.prompt`, `browser.response`, `browser.tool_call`, `browser.session_start`, `browser.session_end`, `browser.capture_miss`
|
|
34
|
+
- `vscode.prompt_submit`, `vscode.tool_call`, `vscode.session_start`
|
|
35
|
+
- `drift.detected`, `notification.fired`
|
|
36
|
+
|
|
37
|
+
### Tests
|
|
38
|
+
- **14 new tests** in `tests/detector.test.ts` with 11 hand-written NDJSON fixtures in `tests/__fixtures__/audit-logs/`. One catalog test per heuristic + its negative ("similar prompts" fires loop; "different prompts" doesn't). Plus 2 integration tests on `detect()` and evidence cap.
|
|
39
|
+
- **196 / 196 tests passing total** (was 182).
|
|
40
|
+
|
|
41
|
+
### Companion release
|
|
42
|
+
- **`@compr/opscontext-chrome@0.1.0`** — new Chrome extension scaffold under `chrome-extension/`. Captures Claude.ai + ChatGPT prompts/responses/tool-calls, streams them via the new `POST /events` endpoint. Not yet on the Chrome Web Store; loadable unpacked via `chrome://extensions` → Developer mode → "Load unpacked" → pick `chrome-extension/dist/`. BSL-1.1 license; selector seeds attributed to MIT prior art in `chrome-extension/LICENSE_THIRD_PARTY.md`.
|
|
43
|
+
- **`css-llc.contextengine@0.9.0`** — VS Code extension companion release that emits `vscode.prompt_submit` and `vscode.tool_call` events into the audit log via the new `emit-event` CLI.
|
|
44
|
+
|
|
45
|
+
### Day-1 test plan
|
|
46
|
+
[`docs/test-plans/PHASE1_DAY1.md`](docs/test-plans/PHASE1_DAY1.md) — 10-step, ~15-minute end-to-end verification. Starts with `init-extension-secret`, ends with deliberate `fabrication_suspect` + `silent_failure` triggers verifying `watch` exits code 2.
|
|
47
|
+
|
|
5
48
|
## [2.0.2] — 2026-06-11 — HTML score report browser tab title → OpsContext
|
|
6
49
|
|
|
7
50
|
Tiny patch release. One change:
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass";
|
|
1
|
+
export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
package/dist/cli.js
CHANGED
|
@@ -1212,6 +1212,193 @@ tamper-evident audit log at ~/.contextengine/audit.log.`);
|
|
|
1212
1212
|
console.error(`Unknown hook subcommand: ${sub}. Try 'contextengine hook --help'.`);
|
|
1213
1213
|
process.exit(1);
|
|
1214
1214
|
}
|
|
1215
|
+
async function cliEmitEvent(args) {
|
|
1216
|
+
const { safeAppend } = await import("./audit.js");
|
|
1217
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
1218
|
+
if (help || args.length < 2) {
|
|
1219
|
+
console.log(`Usage: contextengine emit-event <event-kind> <payload-json> [--actor NAME]
|
|
1220
|
+
|
|
1221
|
+
Appends a single event to the hash-chained audit log. Useful for VS Code
|
|
1222
|
+
extensions, custom integrations, or scripted test scenarios.
|
|
1223
|
+
|
|
1224
|
+
event-kind One of: browser.* / vscode.* / cli.* / learning.* / etc.
|
|
1225
|
+
payload-json A JSON object describing the event. Will be validated as
|
|
1226
|
+
a Record<string, unknown>.
|
|
1227
|
+
--actor NAME Override the actor field. Defaults to 'cli'.
|
|
1228
|
+
|
|
1229
|
+
Examples:
|
|
1230
|
+
contextengine emit-event vscode.tool_call '{"tool":"Edit","args_preview":"file=src/x.ts"}'
|
|
1231
|
+
contextengine emit-event browser.prompt '{"surface":"claude.ai","text":"hello","char_count":5}' --actor browser-ext
|
|
1232
|
+
|
|
1233
|
+
The event becomes a regular audit-chain record (prev_hash + hash added by
|
|
1234
|
+
safeAppend), visible via 'contextengine audit-verify' and consumed by the
|
|
1235
|
+
'contextengine watch' detector + 'drift_status' MCP tool.`);
|
|
1236
|
+
process.exit(help ? 0 : 1);
|
|
1237
|
+
}
|
|
1238
|
+
const eventKind = args[0];
|
|
1239
|
+
const payloadJson = args[1];
|
|
1240
|
+
let actor = "cli";
|
|
1241
|
+
for (let i = 2; i < args.length; i++) {
|
|
1242
|
+
if (args[i] === "--actor" && args[i + 1])
|
|
1243
|
+
actor = args[++i];
|
|
1244
|
+
}
|
|
1245
|
+
let payload;
|
|
1246
|
+
try {
|
|
1247
|
+
const parsed = JSON.parse(payloadJson);
|
|
1248
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1249
|
+
throw new Error("payload must be a JSON object (not array, not primitive)");
|
|
1250
|
+
}
|
|
1251
|
+
payload = parsed;
|
|
1252
|
+
}
|
|
1253
|
+
catch (e) {
|
|
1254
|
+
console.error(`Bad payload JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
1255
|
+
process.exit(1);
|
|
1256
|
+
}
|
|
1257
|
+
// Cast — the audit module accepts any string for the event field; the
|
|
1258
|
+
// AuditEvent union is documentation, not enforcement. Validation of
|
|
1259
|
+
// "what's a valid event kind" is the caller's responsibility (the HTTP
|
|
1260
|
+
// server enforces a prefix allow-list; this CLI is trusted).
|
|
1261
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1262
|
+
safeAppend(eventKind, payload, actor);
|
|
1263
|
+
console.log(`✅ Appended ${eventKind} to audit log.`);
|
|
1264
|
+
}
|
|
1265
|
+
async function cliWatch(args) {
|
|
1266
|
+
const { watchAuditLog, detect } = await import("./detector.js");
|
|
1267
|
+
let jsonMode = false;
|
|
1268
|
+
let once = false;
|
|
1269
|
+
let minSeverity = "info";
|
|
1270
|
+
let windowSeconds = 300;
|
|
1271
|
+
for (let i = 0; i < args.length; i++) {
|
|
1272
|
+
const a = args[i];
|
|
1273
|
+
if (a === "--json")
|
|
1274
|
+
jsonMode = true;
|
|
1275
|
+
else if (a === "--once")
|
|
1276
|
+
once = true;
|
|
1277
|
+
else if (a === "--severity" && args[i + 1]) {
|
|
1278
|
+
const sev = args[++i];
|
|
1279
|
+
if (sev !== "info" && sev !== "warn" && sev !== "critical") {
|
|
1280
|
+
console.error(`Unknown severity: ${sev}. Try info|warn|critical.`);
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
minSeverity = sev;
|
|
1284
|
+
}
|
|
1285
|
+
else if (a === "--window" && args[i + 1]) {
|
|
1286
|
+
windowSeconds = parseInt(args[++i], 10) || 300;
|
|
1287
|
+
}
|
|
1288
|
+
else if (a === "-h" || a === "--help") {
|
|
1289
|
+
console.log(`Usage: contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
|
|
1290
|
+
|
|
1291
|
+
Streams drift / hallucination / loop / stuck-tool / context-bloat alerts as
|
|
1292
|
+
they're detected in ~/.contextengine/audit.log.
|
|
1293
|
+
|
|
1294
|
+
--json One line of NDJSON per alert (for log aggregators / jq).
|
|
1295
|
+
--severity X Floor filter. Default: info (everything).
|
|
1296
|
+
--once Run a single scan over the recent window and exit.
|
|
1297
|
+
Good for cron / health checks.
|
|
1298
|
+
--window SECONDS How far back to scan in --once mode. Default: 300.
|
|
1299
|
+
|
|
1300
|
+
Exit codes:
|
|
1301
|
+
0 clean (or --once found no critical signals)
|
|
1302
|
+
2 --once found at least one critical signal — useful in CI pipelines
|
|
1303
|
+
|
|
1304
|
+
Signals also append a 'drift.detected' record to the audit log (hash-chained)
|
|
1305
|
+
so the alerting itself is auditable.
|
|
1306
|
+
|
|
1307
|
+
Status-bar / OS-notification integration is via the VS Code extension —
|
|
1308
|
+
this CLI is for terminal users, CI, and cron.`);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const sevOrder = { info: 0, warn: 1, critical: 2 };
|
|
1313
|
+
const passesFilter = (s) => sevOrder[s.severity] >= sevOrder[minSeverity];
|
|
1314
|
+
const fmt = (s) => {
|
|
1315
|
+
if (jsonMode) {
|
|
1316
|
+
return JSON.stringify({
|
|
1317
|
+
ts: new Date(s.detectedAt).toISOString(),
|
|
1318
|
+
kind: s.kind,
|
|
1319
|
+
severity: s.severity,
|
|
1320
|
+
reason: s.reason,
|
|
1321
|
+
payload: s.payload,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
const sev = s.severity === "critical" ? "CRIT " : s.severity === "warn" ? "WARN " : "INFO ";
|
|
1325
|
+
const t = new Date(s.detectedAt).toISOString().slice(11, 19);
|
|
1326
|
+
return `[${t}] ${sev} ${s.kind.padEnd(20)} ${s.reason}`;
|
|
1327
|
+
};
|
|
1328
|
+
if (once) {
|
|
1329
|
+
const signals = detect({ windowSeconds }).filter(passesFilter);
|
|
1330
|
+
for (const s of signals) {
|
|
1331
|
+
console.log(fmt(s));
|
|
1332
|
+
}
|
|
1333
|
+
const hasCritical = signals.some((s) => s.severity === "critical");
|
|
1334
|
+
process.exit(hasCritical ? 2 : 0);
|
|
1335
|
+
}
|
|
1336
|
+
if (!jsonMode) {
|
|
1337
|
+
console.error("[opscontext watch] streaming drift signals (Ctrl-C to exit)…");
|
|
1338
|
+
}
|
|
1339
|
+
const dispose = watchAuditLog((s) => {
|
|
1340
|
+
if (passesFilter(s))
|
|
1341
|
+
console.log(fmt(s));
|
|
1342
|
+
}, { windowSeconds });
|
|
1343
|
+
process.on("SIGINT", () => {
|
|
1344
|
+
dispose();
|
|
1345
|
+
if (!jsonMode)
|
|
1346
|
+
console.error("\n[opscontext watch] stopped.");
|
|
1347
|
+
process.exit(0);
|
|
1348
|
+
});
|
|
1349
|
+
// Keep alive — the watcher uses internal timers, but a stdin listener also
|
|
1350
|
+
// helps catch terminal closes.
|
|
1351
|
+
process.stdin.resume();
|
|
1352
|
+
}
|
|
1353
|
+
async function cliInitExtensionSecret(args) {
|
|
1354
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
1355
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
1356
|
+
if (help) {
|
|
1357
|
+
console.log(`Usage: contextengine init-extension-secret [--force]
|
|
1358
|
+
|
|
1359
|
+
Generates a 32-byte hex token at ~/.contextengine/extension-secret (mode 0600)
|
|
1360
|
+
and prints it to stdout. The OpsContext browser extension reads the same
|
|
1361
|
+
token from its options page; both sides must match for events to flow.
|
|
1362
|
+
|
|
1363
|
+
--force, -f Overwrite an existing secret. Default is to refuse if one
|
|
1364
|
+
already exists (prevents accidental rotation that would
|
|
1365
|
+
disconnect the extension until it's re-pasted).
|
|
1366
|
+
|
|
1367
|
+
After running, paste the printed value into the extension's Options page
|
|
1368
|
+
(Cmd+Shift+P → "Open extension options" in Chrome, or click the extension
|
|
1369
|
+
icon → Options).`);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const { randomBytes } = await import("crypto");
|
|
1373
|
+
const { writeFileSync, existsSync, chmodSync, mkdirSync } = await import("fs");
|
|
1374
|
+
const { join } = await import("path");
|
|
1375
|
+
const { homedir } = await import("os");
|
|
1376
|
+
const dir = join(homedir(), ".contextengine");
|
|
1377
|
+
const path = join(dir, "extension-secret");
|
|
1378
|
+
if (existsSync(path) && !force) {
|
|
1379
|
+
console.error(`❌ ${path} already exists. Re-running would invalidate any extension that already has the old value pasted in.\n` +
|
|
1380
|
+
`\n` +
|
|
1381
|
+
` Pass --force to rotate (you'll need to re-paste the new value in the extension's Options page).\n` +
|
|
1382
|
+
` Or read the current secret with: cat ${path}`);
|
|
1383
|
+
process.exit(1);
|
|
1384
|
+
}
|
|
1385
|
+
mkdirSync(dir, { recursive: true });
|
|
1386
|
+
const secret = randomBytes(32).toString("hex");
|
|
1387
|
+
writeFileSync(path, secret + "\n", { mode: 0o600 });
|
|
1388
|
+
try {
|
|
1389
|
+
chmodSync(path, 0o600);
|
|
1390
|
+
}
|
|
1391
|
+
catch { /* best-effort */ }
|
|
1392
|
+
console.log(`✅ Wrote ${path} (mode 600)\n`);
|
|
1393
|
+
console.log(`Secret (paste this into the browser extension's Options page):\n`);
|
|
1394
|
+
console.log(` ${secret}\n`);
|
|
1395
|
+
console.log(`Next steps:`);
|
|
1396
|
+
console.log(` 1. Open Chrome → chrome://extensions → Find "OpsContext Browser Capture"`);
|
|
1397
|
+
console.log(` 2. Click "Options" → paste the secret → Save`);
|
|
1398
|
+
console.log(` 3. Visit https://claude.ai or https://chatgpt.com — events will flow.`);
|
|
1399
|
+
console.log(`\nThe MCP server's event-ingest endpoint is at http://127.0.0.1:7842/events`);
|
|
1400
|
+
console.log(`(GET /health to verify it's running).`);
|
|
1401
|
+
}
|
|
1215
1402
|
async function cliPolicy(args) {
|
|
1216
1403
|
const sub = args[0];
|
|
1217
1404
|
if (!sub || sub === "-h" || sub === "--help") {
|
|
@@ -1595,6 +1782,12 @@ Usage:
|
|
|
1595
1782
|
contextengine audit-verify Verify audit log chain integrity (tamper detection)
|
|
1596
1783
|
contextengine policy <validate|show> [args]
|
|
1597
1784
|
Author + validate the declarative .contextengine/policy.json
|
|
1785
|
+
contextengine init-extension-secret [--force]
|
|
1786
|
+
Generate ~/.contextengine/extension-secret for the browser ext
|
|
1787
|
+
contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
|
|
1788
|
+
Stream drift / loop / stuck-tool / fabrication alerts from the audit log
|
|
1789
|
+
contextengine emit-event <kind> <payload-json> [--actor NAME]
|
|
1790
|
+
Append a single event to the audit log (for integrations / scripted tests)
|
|
1598
1791
|
contextengine hook <secret-scan|doc-coverage>
|
|
1599
1792
|
Run policy-driven pre-commit checks against staged diff
|
|
1600
1793
|
(exit 1 on blocking violation; CE_JSON=1 for CI output)
|
|
@@ -1793,6 +1986,24 @@ else if (command === "activate") {
|
|
|
1793
1986
|
process.exit(1);
|
|
1794
1987
|
});
|
|
1795
1988
|
}
|
|
1989
|
+
else if (command === "emit-event") {
|
|
1990
|
+
cliEmitEvent(process.argv.slice(3)).catch((err) => {
|
|
1991
|
+
console.error("Error:", err);
|
|
1992
|
+
process.exit(1);
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
else if (command === "watch") {
|
|
1996
|
+
cliWatch(process.argv.slice(3)).catch((err) => {
|
|
1997
|
+
console.error("Error:", err);
|
|
1998
|
+
process.exit(1);
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
else if (command === "init-extension-secret") {
|
|
2002
|
+
cliInitExtensionSecret(process.argv.slice(3)).catch((err) => {
|
|
2003
|
+
console.error("Error:", err);
|
|
2004
|
+
process.exit(1);
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
1796
2007
|
else if (command === "stats") {
|
|
1797
2008
|
cliStats();
|
|
1798
2009
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type AuditRecord, type AuditEvent } from "./audit.js";
|
|
2
|
+
export type DriftKind = "loop" | "stuck" | "context_bloat" | "fabrication_suspect" | "drift" | "no_insight" | "stale_doc_signal" | "silent_failure";
|
|
3
|
+
export type Severity = "info" | "warn" | "critical";
|
|
4
|
+
export interface DriftSignal {
|
|
5
|
+
kind: DriftKind;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
reason: string;
|
|
8
|
+
evidence: AuditRecord[];
|
|
9
|
+
payload: Record<string, unknown>;
|
|
10
|
+
detectedAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface DetectorOptions {
|
|
13
|
+
/** Window in seconds for event scan. Default 300 (5 min). */
|
|
14
|
+
windowSeconds?: number;
|
|
15
|
+
/** Inject a "now" for deterministic tests. */
|
|
16
|
+
now?: number;
|
|
17
|
+
/** Inject the events instead of reading from disk (for tests). */
|
|
18
|
+
events?: AuditRecord[];
|
|
19
|
+
/** Project root for fabrication_suspect file-existence checks. */
|
|
20
|
+
cwd?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function scanRecentEvents(windowSeconds?: number, now?: number): AuditRecord[];
|
|
23
|
+
/** Tokenize text for cheap similarity comparisons (Jaccard / BM25-lite). */
|
|
24
|
+
export declare function tokens(s: string): Set<string>;
|
|
25
|
+
export declare function jaccard(a: Set<string>, b: Set<string>): number;
|
|
26
|
+
declare function detectLoop(events: AuditRecord[]): DriftSignal | null;
|
|
27
|
+
declare function detectStuck(events: AuditRecord[], now: number): DriftSignal | null;
|
|
28
|
+
declare function detectContextBloat(events: AuditRecord[]): DriftSignal | null;
|
|
29
|
+
declare function detectFabrication(events: AuditRecord[], cwd: string): DriftSignal | null;
|
|
30
|
+
declare function detectDrift(events: AuditRecord[]): DriftSignal | null;
|
|
31
|
+
declare function detectNoInsight(events: AuditRecord[]): DriftSignal | null;
|
|
32
|
+
declare function detectSilentFailure(events: AuditRecord[], now: number): DriftSignal | null;
|
|
33
|
+
declare function detectStaleDocSignal(_events: AuditRecord[]): DriftSignal | null;
|
|
34
|
+
export declare function runHeuristics(events: AuditRecord[], opts?: {
|
|
35
|
+
now?: number;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
}): DriftSignal[];
|
|
38
|
+
/** Convenience for callers: scan recent events and run heuristics in one call. */
|
|
39
|
+
export declare function detect(opts?: DetectorOptions): DriftSignal[];
|
|
40
|
+
/**
|
|
41
|
+
* Watch the audit log and fire `onAlert` for each new signal. Dedupe key is
|
|
42
|
+
* `kind:reason` kept in an in-memory LRU bounded at 100 entries — prevents
|
|
43
|
+
* the same drift from firing every poll cycle.
|
|
44
|
+
*
|
|
45
|
+
* Returns a dispose function. Caller is responsible for handling SIGINT
|
|
46
|
+
* cleanly.
|
|
47
|
+
*/
|
|
48
|
+
export declare function watchAuditLog(onAlert: (s: DriftSignal) => void, opts?: {
|
|
49
|
+
windowSeconds?: number;
|
|
50
|
+
debounceMs?: number;
|
|
51
|
+
emitAuditEvent?: boolean;
|
|
52
|
+
}): () => void;
|
|
53
|
+
export declare const _internal: {
|
|
54
|
+
detectLoop: typeof detectLoop;
|
|
55
|
+
detectStuck: typeof detectStuck;
|
|
56
|
+
detectContextBloat: typeof detectContextBloat;
|
|
57
|
+
detectFabrication: typeof detectFabrication;
|
|
58
|
+
detectDrift: typeof detectDrift;
|
|
59
|
+
detectNoInsight: typeof detectNoInsight;
|
|
60
|
+
detectSilentFailure: typeof detectSilentFailure;
|
|
61
|
+
detectStaleDocSignal: typeof detectStaleDocSignal;
|
|
62
|
+
};
|
|
63
|
+
export type { AuditRecord, AuditEvent };
|
|
64
|
+
//# sourceMappingURL=detector.d.ts.map
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// 🔒 LOCKED [DRIFT-HEURISTICS] — 2026-06-23
|
|
2
|
+
// ⛔ NEVER make a heuristic fire on a single event in isolation. Every
|
|
3
|
+
// heuristic looks at a WINDOW of events. Single-event triggers will
|
|
4
|
+
// fire on the user's normal workflow and burn trust in the alerts.
|
|
5
|
+
// ⛔ NEVER raise a critical severity from a heuristic without a corresponding
|
|
6
|
+
// audit event (drift.detected with full payload). Critical = OS
|
|
7
|
+
// notification + interrupt — the audit trail is what the user reviews
|
|
8
|
+
// after-the-fact to understand WHY the alert fired.
|
|
9
|
+
// ⛔ NEVER trust the assistant's claim that something exists in the file
|
|
10
|
+
// system. The fabrication_suspect check is precisely about catching
|
|
11
|
+
// those claims. If you add helpers, default to "verify against fs".
|
|
12
|
+
// WHY: Drift alerts have to be precise. False positives train users to
|
|
13
|
+
// ignore the status bar, defeating the entire purpose. Conservative
|
|
14
|
+
// thresholds + window-based detection + auditable trail are the
|
|
15
|
+
// discipline that earns user trust.
|
|
16
|
+
// FIX: To add a new heuristic, copy the shape of detectLoop or detectStuck,
|
|
17
|
+
// keep the predicate pure (no I/O except fs.existsSync), append it to
|
|
18
|
+
// HEURISTICS at the bottom, and add a fixture to tests/__fixtures__/
|
|
19
|
+
// audit-logs/.
|
|
20
|
+
import { readAuditLog, safeAppend } from "./audit.js";
|
|
21
|
+
import { watch, existsSync } from "fs";
|
|
22
|
+
import { join, isAbsolute } from "path";
|
|
23
|
+
import { homedir } from "os";
|
|
24
|
+
// ─── Window scan ───────────────────────────────────────────────────────────
|
|
25
|
+
export function scanRecentEvents(windowSeconds = 300, now = Date.now()) {
|
|
26
|
+
let all;
|
|
27
|
+
try {
|
|
28
|
+
all = readAuditLog();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const cutoff = now - windowSeconds * 1000;
|
|
34
|
+
return all.filter((r) => {
|
|
35
|
+
const ts = Date.parse(r.ts);
|
|
36
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
40
|
+
/** Tokenize text for cheap similarity comparisons (Jaccard / BM25-lite). */
|
|
41
|
+
export function tokens(s) {
|
|
42
|
+
return new Set(String(s || "")
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
|
45
|
+
.split(/\s+/)
|
|
46
|
+
.filter((t) => t.length > 2));
|
|
47
|
+
}
|
|
48
|
+
export function jaccard(a, b) {
|
|
49
|
+
if (a.size === 0 && b.size === 0)
|
|
50
|
+
return 1;
|
|
51
|
+
if (a.size === 0 || b.size === 0)
|
|
52
|
+
return 0;
|
|
53
|
+
let intersect = 0;
|
|
54
|
+
for (const t of a)
|
|
55
|
+
if (b.has(t))
|
|
56
|
+
intersect++;
|
|
57
|
+
return intersect / (a.size + b.size - intersect);
|
|
58
|
+
}
|
|
59
|
+
function groupBy(arr, keyFn) {
|
|
60
|
+
const m = new Map();
|
|
61
|
+
for (const x of arr) {
|
|
62
|
+
const k = keyFn(x);
|
|
63
|
+
const list = m.get(k) || [];
|
|
64
|
+
list.push(x);
|
|
65
|
+
m.set(k, list);
|
|
66
|
+
}
|
|
67
|
+
return m;
|
|
68
|
+
}
|
|
69
|
+
function stableStringify(v) {
|
|
70
|
+
if (v === null || typeof v !== "object")
|
|
71
|
+
return JSON.stringify(v);
|
|
72
|
+
if (Array.isArray(v))
|
|
73
|
+
return "[" + v.map(stableStringify).join(",") + "]";
|
|
74
|
+
const keys = Object.keys(v).sort();
|
|
75
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v[k])).join(",") + "}";
|
|
76
|
+
}
|
|
77
|
+
function getText(r) {
|
|
78
|
+
const p = r.payload || {};
|
|
79
|
+
return String(p.text ?? p.preview ?? p.args_preview ?? "");
|
|
80
|
+
}
|
|
81
|
+
function sessionKey(r) {
|
|
82
|
+
const p = r.payload || {};
|
|
83
|
+
return String(p.conversation_id ?? p.session_name ?? p.session ?? "default");
|
|
84
|
+
}
|
|
85
|
+
function mk(kind, severity, reason, evidence, payload) {
|
|
86
|
+
return {
|
|
87
|
+
kind,
|
|
88
|
+
severity,
|
|
89
|
+
reason,
|
|
90
|
+
evidence: evidence.slice(0, 5),
|
|
91
|
+
payload,
|
|
92
|
+
detectedAt: Date.now(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// ─── Heuristics ────────────────────────────────────────────────────────────
|
|
96
|
+
function detectLoop(events) {
|
|
97
|
+
const prompts = events
|
|
98
|
+
.filter((e) => e.event === "browser.prompt" || e.event === "vscode.prompt_submit")
|
|
99
|
+
.slice(-10);
|
|
100
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
101
|
+
let dupes = 0;
|
|
102
|
+
const matches = [prompts[i]];
|
|
103
|
+
const ti = tokens(getText(prompts[i]));
|
|
104
|
+
if (ti.size === 0)
|
|
105
|
+
continue;
|
|
106
|
+
for (let j = i + 1; j < prompts.length; j++) {
|
|
107
|
+
const tj = tokens(getText(prompts[j]));
|
|
108
|
+
// 0.6 Jaccard ≈ 60% token overlap. Lower than initially gut-felt
|
|
109
|
+
// because real prompt loops include "let me rephrase" wording shifts
|
|
110
|
+
// that drop overlap quickly. 0.85 missed the "fix login bug" /
|
|
111
|
+
// "login bug fix please" pattern; 0.6 catches it without falsing on
|
|
112
|
+
// genuinely different prompts that happen to share verbs.
|
|
113
|
+
if (jaccard(ti, tj) > 0.6 &&
|
|
114
|
+
Date.parse(prompts[j].ts) - Date.parse(prompts[i].ts) < 300_000) {
|
|
115
|
+
dupes++;
|
|
116
|
+
matches.push(prompts[j]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (dupes >= 2) {
|
|
120
|
+
const snippet = getText(prompts[i]).slice(0, 80);
|
|
121
|
+
return mk("loop", "warn", `Same prompt sent ${dupes + 1}× in 5 min`, matches, { repetitions: dupes + 1, snippet });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function detectStuck(events, now) {
|
|
127
|
+
const calls = events.filter((e) => e.event === "browser.tool_call" || e.event === "vscode.tool_call");
|
|
128
|
+
// 5-minute window. The "3 identical calls in a row" pattern usually plays
|
|
129
|
+
// out over a few minutes — the user retries, waits, retries, gives up,
|
|
130
|
+
// retries again. 2 min was too tight for normal LLM-agent rhythms.
|
|
131
|
+
const recent = calls.filter((c) => now - Date.parse(c.ts) < 300_000);
|
|
132
|
+
const byKey = groupBy(recent, (c) => {
|
|
133
|
+
const p = c.payload || {};
|
|
134
|
+
return `${p.tool}:${stableStringify(p.args ?? p.args_preview ?? "")}`;
|
|
135
|
+
});
|
|
136
|
+
for (const group of byKey.values()) {
|
|
137
|
+
if (group.length >= 3) {
|
|
138
|
+
const tool = String(group[0].payload?.tool ?? "unknown");
|
|
139
|
+
return mk("stuck", "warn", `Tool ${tool} called ${group.length}× with identical args in 2 min`, group, { tool, count: group.length, args_preview: String(group[0].payload?.args_preview ?? "") });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function detectContextBloat(events) {
|
|
145
|
+
const bySession = groupBy(events, sessionKey);
|
|
146
|
+
for (const [sid, group] of bySession) {
|
|
147
|
+
let tokensSum = 0;
|
|
148
|
+
let hasSave = false;
|
|
149
|
+
for (const e of group) {
|
|
150
|
+
const p = e.payload || {};
|
|
151
|
+
if (typeof p.tokens === "number")
|
|
152
|
+
tokensSum += p.tokens;
|
|
153
|
+
else if (typeof p.char_count === "number")
|
|
154
|
+
tokensSum += Math.ceil(p.char_count / 4);
|
|
155
|
+
if (e.event === "session.save")
|
|
156
|
+
hasSave = true;
|
|
157
|
+
}
|
|
158
|
+
if (tokensSum > 80_000 && !hasSave) {
|
|
159
|
+
return mk("context_bloat", "warn", `Session "${sid}" at ~${Math.round(tokensSum / 1000)}K tokens, no save_session yet`, group.slice(-3), { sessionId: sid, approxTokens: tokensSum });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
function detectFabrication(events, cwd) {
|
|
165
|
+
const responses = events.filter((e) => e.event === "browser.response").slice(-5);
|
|
166
|
+
// Match file paths with a line number: "src/foo.ts:42", "lib/x.py:3-7", etc.
|
|
167
|
+
// Conservative: only flag paths with a recognizable code extension AND a line ref.
|
|
168
|
+
const re = /([A-Za-z0-9_\-./]+\.(?:ts|tsx|js|jsx|py|md|json|yml|yaml|go|rs|rb|java|cs|cpp|c|h)):\d+/g;
|
|
169
|
+
for (const r of responses) {
|
|
170
|
+
const text = getText(r);
|
|
171
|
+
const found = new Set();
|
|
172
|
+
for (const m of text.matchAll(re)) {
|
|
173
|
+
const p = m[1];
|
|
174
|
+
if (found.has(p))
|
|
175
|
+
continue;
|
|
176
|
+
found.add(p);
|
|
177
|
+
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
178
|
+
if (!existsSync(abs)) {
|
|
179
|
+
return mk("fabrication_suspect", "critical", `Assistant referenced non-existent file: ${p}`, [r], { citedPath: p, responseHash: r.hash });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
function detectDrift(events) {
|
|
186
|
+
// Per-session: if the last 3 prompts are jointly far (low overlap) from
|
|
187
|
+
// the session's FIRST prompt, the conversation has drifted off-topic.
|
|
188
|
+
const bySession = groupBy(events.filter((e) => e.event === "browser.prompt" || e.event === "vscode.prompt_submit"), sessionKey);
|
|
189
|
+
for (const [sid, prompts] of bySession) {
|
|
190
|
+
if (prompts.length < 4)
|
|
191
|
+
continue;
|
|
192
|
+
const first = tokens(getText(prompts[0]));
|
|
193
|
+
if (first.size === 0)
|
|
194
|
+
continue;
|
|
195
|
+
const last3 = prompts.slice(-3).map((p) => tokens(getText(p)));
|
|
196
|
+
const avgSim = last3.reduce((sum, t) => sum + jaccard(first, t), 0) / 3;
|
|
197
|
+
if (avgSim < 0.10) {
|
|
198
|
+
return mk("drift", "info", `Session "${sid}" has drifted from its opening prompt (similarity ${avgSim.toFixed(2)})`, prompts.slice(-3), { sessionId: sid, similarity: avgSim, firstPrompt: getText(prompts[0]).slice(0, 80) });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
function detectNoInsight(events) {
|
|
204
|
+
const lastLearn = [...events].reverse().find((e) => e.event === "learning.save");
|
|
205
|
+
const since = lastLearn ? Date.parse(lastLearn.ts) : 0;
|
|
206
|
+
const toolCalls = events.filter((e) => (e.event === "browser.tool_call" ||
|
|
207
|
+
e.event === "vscode.tool_call") &&
|
|
208
|
+
Date.parse(e.ts) > since);
|
|
209
|
+
if (toolCalls.length >= 30) {
|
|
210
|
+
return mk("no_insight", "info", `${toolCalls.length} tool calls since the last save_learning`, toolCalls.slice(-3), { toolCallCount: toolCalls.length, lastLearningAt: since || null });
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
function detectSilentFailure(events, now) {
|
|
215
|
+
const errs = events.filter((e) => {
|
|
216
|
+
if (e.event !== "browser.tool_call" && e.event !== "vscode.tool_call")
|
|
217
|
+
return false;
|
|
218
|
+
const p = e.payload || {};
|
|
219
|
+
return Boolean(p.error || p.failed || p.status === "error");
|
|
220
|
+
});
|
|
221
|
+
const recent = errs.filter((e) => now - Date.parse(e.ts) < 300_000);
|
|
222
|
+
const byTool = groupBy(recent, (e) => String(e.payload?.tool ?? "unknown"));
|
|
223
|
+
for (const [tool, group] of byTool) {
|
|
224
|
+
if (group.length >= 3) {
|
|
225
|
+
const snippet = String((group[0].payload?.error || group[0].payload?.message || "")).slice(0, 200);
|
|
226
|
+
return mk("silent_failure", "critical", `Tool ${tool} failed ${group.length}× in 5 min`, group, { tool, errorSnippet: snippet, count: group.length });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function detectStaleDocSignal(_events) {
|
|
232
|
+
// Stub: full implementation requires reading .contextengine/policy.json
|
|
233
|
+
// and matching staged edits to doc_coverage rules. Defer to Phase 3.1
|
|
234
|
+
// when the policy module exposes a helper. For now, return null so the
|
|
235
|
+
// heuristic is wired but inert (keeps the union complete + lets tests
|
|
236
|
+
// assert "no signal" against fixtures that don't trigger it).
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
// ─── Runner ────────────────────────────────────────────────────────────────
|
|
240
|
+
export function runHeuristics(events, opts = {}) {
|
|
241
|
+
const now = opts.now ?? Date.now();
|
|
242
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
243
|
+
const signals = [];
|
|
244
|
+
const push = (s) => { if (s)
|
|
245
|
+
signals.push(s); };
|
|
246
|
+
push(detectLoop(events));
|
|
247
|
+
push(detectStuck(events, now));
|
|
248
|
+
push(detectContextBloat(events));
|
|
249
|
+
push(detectFabrication(events, cwd));
|
|
250
|
+
push(detectDrift(events));
|
|
251
|
+
push(detectNoInsight(events));
|
|
252
|
+
push(detectSilentFailure(events, now));
|
|
253
|
+
push(detectStaleDocSignal(events));
|
|
254
|
+
return signals;
|
|
255
|
+
}
|
|
256
|
+
/** Convenience for callers: scan recent events and run heuristics in one call. */
|
|
257
|
+
export function detect(opts = {}) {
|
|
258
|
+
const now = opts.now ?? Date.now();
|
|
259
|
+
const events = opts.events ?? scanRecentEvents(opts.windowSeconds ?? 300, now);
|
|
260
|
+
return runHeuristics(events, { now, cwd: opts.cwd });
|
|
261
|
+
}
|
|
262
|
+
// ─── Live watcher (CLI + MCP) ──────────────────────────────────────────────
|
|
263
|
+
/**
|
|
264
|
+
* Watch the audit log and fire `onAlert` for each new signal. Dedupe key is
|
|
265
|
+
* `kind:reason` kept in an in-memory LRU bounded at 100 entries — prevents
|
|
266
|
+
* the same drift from firing every poll cycle.
|
|
267
|
+
*
|
|
268
|
+
* Returns a dispose function. Caller is responsible for handling SIGINT
|
|
269
|
+
* cleanly.
|
|
270
|
+
*/
|
|
271
|
+
export function watchAuditLog(onAlert, opts = {}) {
|
|
272
|
+
const auditPath = join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "audit.log");
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const seenOrder = [];
|
|
275
|
+
const SEEN_CAP = 100;
|
|
276
|
+
function maybeFire(signal) {
|
|
277
|
+
const key = `${signal.kind}:${signal.reason}`;
|
|
278
|
+
if (seen.has(key))
|
|
279
|
+
return;
|
|
280
|
+
seen.add(key);
|
|
281
|
+
seenOrder.push(key);
|
|
282
|
+
if (seenOrder.length > SEEN_CAP) {
|
|
283
|
+
const evict = seenOrder.shift();
|
|
284
|
+
if (evict)
|
|
285
|
+
seen.delete(evict);
|
|
286
|
+
}
|
|
287
|
+
onAlert(signal);
|
|
288
|
+
if (opts.emitAuditEvent !== false) {
|
|
289
|
+
safeAppend("drift.detected", {
|
|
290
|
+
kind: signal.kind,
|
|
291
|
+
severity: signal.severity,
|
|
292
|
+
reason: signal.reason,
|
|
293
|
+
evidence_count: signal.evidence.length,
|
|
294
|
+
...signal.payload,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function tick() {
|
|
299
|
+
const events = scanRecentEvents(opts.windowSeconds ?? 300);
|
|
300
|
+
const signals = runHeuristics(events);
|
|
301
|
+
for (const s of signals)
|
|
302
|
+
maybeFire(s);
|
|
303
|
+
}
|
|
304
|
+
// Initial scan.
|
|
305
|
+
tick();
|
|
306
|
+
let debounceTimer = null;
|
|
307
|
+
const debounce = opts.debounceMs ?? 250;
|
|
308
|
+
let watcher = null;
|
|
309
|
+
try {
|
|
310
|
+
if (existsSync(auditPath)) {
|
|
311
|
+
watcher = watch(auditPath, { persistent: false }, () => {
|
|
312
|
+
if (debounceTimer)
|
|
313
|
+
clearTimeout(debounceTimer);
|
|
314
|
+
debounceTimer = setTimeout(tick, debounce);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
/* fs.watch unsupported on some platforms — fall back to polling below */
|
|
320
|
+
}
|
|
321
|
+
// Poll-based fallback (in case watch doesn't fire, e.g., remote FS).
|
|
322
|
+
const pollTimer = setInterval(tick, 5_000);
|
|
323
|
+
return () => {
|
|
324
|
+
if (watcher)
|
|
325
|
+
watcher.close();
|
|
326
|
+
clearInterval(pollTimer);
|
|
327
|
+
if (debounceTimer)
|
|
328
|
+
clearTimeout(debounceTimer);
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
// Test-only — clear the dedupe LRU between fixture runs.
|
|
332
|
+
export const _internal = {
|
|
333
|
+
detectLoop, detectStuck, detectContextBloat, detectFabrication,
|
|
334
|
+
detectDrift, detectNoInsight, detectSilentFailure, detectStaleDocSignal,
|
|
335
|
+
};
|
|
336
|
+
//# sourceMappingURL=detector.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
interface IncomingEvent {
|
|
2
|
+
v?: number;
|
|
3
|
+
ts?: string;
|
|
4
|
+
event?: string;
|
|
5
|
+
actor?: string;
|
|
6
|
+
payload?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
/** Hot-reload the secret from disk so the CLI can rotate without restarting MCP. */
|
|
9
|
+
declare function loadSecret(): string | null;
|
|
10
|
+
declare function constantTimeEqual(a: string, b: string): boolean;
|
|
11
|
+
/** Validate an event has the minimum shape we'll write to audit. */
|
|
12
|
+
declare function validateEvent(e: IncomingEvent, idx: number): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Boot the local event-ingest HTTP server. Returns the listening port or null
|
|
15
|
+
* if the port was already in use (caller may decide whether to retry on
|
|
16
|
+
* another port or surface the error).
|
|
17
|
+
*
|
|
18
|
+
* Safe to call multiple times — second call returns the existing server.
|
|
19
|
+
*/
|
|
20
|
+
export declare function startEventIngestServer(): Promise<number | null>;
|
|
21
|
+
export declare function stopEventIngestServer(): Promise<void>;
|
|
22
|
+
export declare const _internal: {
|
|
23
|
+
loadSecret: typeof loadSecret;
|
|
24
|
+
constantTimeEqual: typeof constantTimeEqual;
|
|
25
|
+
validateEvent: typeof validateEvent;
|
|
26
|
+
SECRET_FILE: string;
|
|
27
|
+
PORT: number;
|
|
28
|
+
};
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=http-server.d.ts.map
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// 🔒 LOCKED [HTTP-EVENT-INGEST] — 2026-06-23
|
|
2
|
+
// ⛔ NEVER bind to 0.0.0.0 — only 127.0.0.1. The threat model is "browser
|
|
3
|
+
// extension running on the same machine"; a network-reachable port would
|
|
4
|
+
// let any device on the LAN inject audit events.
|
|
5
|
+
// ⛔ NEVER compare the secret with `===` — use timingSafeEqual. String compare
|
|
6
|
+
// leaks timing info that lets a remote attacker brute-force the secret
|
|
7
|
+
// one byte at a time.
|
|
8
|
+
// ⛔ NEVER auto-generate the secret on first request. The CLI must create it
|
|
9
|
+
// explicitly (so a stray client can't bootstrap itself into the audit log).
|
|
10
|
+
// Refuse with 401 if ~/.contextengine/extension-secret is missing.
|
|
11
|
+
// ⛔ NEVER write events before validating shape — a malformed event in the
|
|
12
|
+
// audit log corrupts the chain verifier and ruins compliance evidence.
|
|
13
|
+
// WHY: This is the only network surface OpsContext exposes locally. Every
|
|
14
|
+
// decision here is about keeping it auth-required, scope-bound, and shape-
|
|
15
|
+
// validated, because the audit log is the foundation everything else
|
|
16
|
+
// builds on.
|
|
17
|
+
// FIX: To add more endpoints, follow the same auth + validation pattern. Do
|
|
18
|
+
// not add a /raw-write or /admin route without a separate secret + a
|
|
19
|
+
// separate LOCK comment explaining why.
|
|
20
|
+
import * as http from "http";
|
|
21
|
+
import { existsSync, readFileSync } from "fs";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
import { homedir } from "os";
|
|
24
|
+
import { timingSafeEqual } from "crypto";
|
|
25
|
+
import { safeAppend } from "./audit.js";
|
|
26
|
+
const PORT = parseInt(process.env.OPSCONTEXT_EVENT_PORT || "7842", 10);
|
|
27
|
+
const HOST = "127.0.0.1";
|
|
28
|
+
const SECRET_FILE = join(homedir(), ".contextengine", "extension-secret");
|
|
29
|
+
const MAX_BODY = 64 * 1024; // 64 KB per batch
|
|
30
|
+
const MAX_BATCH = 50;
|
|
31
|
+
let serverInstance = null;
|
|
32
|
+
/** Hot-reload the secret from disk so the CLI can rotate without restarting MCP. */
|
|
33
|
+
function loadSecret() {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(SECRET_FILE))
|
|
36
|
+
return null;
|
|
37
|
+
return readFileSync(SECRET_FILE, "utf-8").trim();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function constantTimeEqual(a, b) {
|
|
44
|
+
// timingSafeEqual requires equal-length buffers — short-circuit on mismatch
|
|
45
|
+
// length but only via Buffer.byteLength so we don't leak via string-length
|
|
46
|
+
// comparison early-exit. Acceptable because length isn't a secret.
|
|
47
|
+
if (a.length !== b.length)
|
|
48
|
+
return false;
|
|
49
|
+
try {
|
|
50
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Validate an event has the minimum shape we'll write to audit. */
|
|
57
|
+
function validateEvent(e, idx) {
|
|
58
|
+
if (typeof e !== "object" || e === null)
|
|
59
|
+
return `events[${idx}]: not an object`;
|
|
60
|
+
if (e.v !== 1)
|
|
61
|
+
return `events[${idx}]: missing or unsupported version field (v=${e.v})`;
|
|
62
|
+
if (typeof e.event !== "string" || !e.event)
|
|
63
|
+
return `events[${idx}]: missing event kind`;
|
|
64
|
+
if (typeof e.ts !== "string" || !e.ts)
|
|
65
|
+
return `events[${idx}]: missing ts`;
|
|
66
|
+
if (typeof e.payload !== "object" || e.payload === null)
|
|
67
|
+
return `events[${idx}]: missing payload object`;
|
|
68
|
+
// Restrict event kinds to the browser.* + vscode.* + cli.* namespaces.
|
|
69
|
+
// The audit module's own writers use other kinds (learning.save etc.);
|
|
70
|
+
// those events come from the LOCAL server, not the network surface.
|
|
71
|
+
if (!/^(browser|vscode|cli)\./.test(e.event)) {
|
|
72
|
+
return `events[${idx}]: event kind '${e.event}' not allowed via HTTP (only browser.*/vscode.*/cli.*)`;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function sendJson(res, status, body) {
|
|
77
|
+
const json = JSON.stringify(body);
|
|
78
|
+
res.writeHead(status, {
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
"Content-Length": Buffer.byteLength(json),
|
|
81
|
+
// Belt-and-braces: even though the manifest's host_permissions already
|
|
82
|
+
// lets the SW POST without preflight, we set the response header so
|
|
83
|
+
// future popup-side probe pings work too.
|
|
84
|
+
"Access-Control-Allow-Origin": "*",
|
|
85
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
86
|
+
"Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
|
|
87
|
+
});
|
|
88
|
+
res.end(json);
|
|
89
|
+
}
|
|
90
|
+
function handleEvents(req, res) {
|
|
91
|
+
const secret = loadSecret();
|
|
92
|
+
if (!secret) {
|
|
93
|
+
sendJson(res, 401, {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: "no_secret_configured",
|
|
96
|
+
hint: "Run: contextengine init-extension-secret",
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const provided = req.headers["x-opscontext-secret"];
|
|
101
|
+
if (typeof provided !== "string" || !constantTimeEqual(provided, secret)) {
|
|
102
|
+
sendJson(res, 401, { ok: false, error: "bad_secret" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
const chunks = [];
|
|
107
|
+
req.on("data", (chunk) => {
|
|
108
|
+
bytes += chunk.length;
|
|
109
|
+
if (bytes > MAX_BODY) {
|
|
110
|
+
req.destroy();
|
|
111
|
+
sendJson(res, 413, { ok: false, error: "payload_too_large", limit: MAX_BODY });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
chunks.push(chunk);
|
|
115
|
+
});
|
|
116
|
+
req.on("end", () => {
|
|
117
|
+
let batch;
|
|
118
|
+
try {
|
|
119
|
+
batch = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
sendJson(res, 400, { ok: false, error: "bad_json" });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!batch || !Array.isArray(batch.events)) {
|
|
126
|
+
sendJson(res, 400, { ok: false, error: "missing_events_array" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (batch.events.length > MAX_BATCH) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: "batch_too_large", limit: MAX_BATCH });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Validate every event BEFORE writing any of them.
|
|
134
|
+
for (let i = 0; i < batch.events.length; i++) {
|
|
135
|
+
const err = validateEvent(batch.events[i], i);
|
|
136
|
+
if (err) {
|
|
137
|
+
sendJson(res, 400, { ok: false, error: "invalid_event", detail: err });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// All valid — write them to audit log via safeAppend.
|
|
142
|
+
let written = 0;
|
|
143
|
+
for (const ev of batch.events) {
|
|
144
|
+
const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
|
|
145
|
+
// event/payload were validated above — cast is safe.
|
|
146
|
+
safeAppend(ev.event, ev.payload, actor);
|
|
147
|
+
written++;
|
|
148
|
+
}
|
|
149
|
+
sendJson(res, 200, { ok: true, written });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function handleHealth(_req, res) {
|
|
153
|
+
sendJson(res, 200, {
|
|
154
|
+
ok: true,
|
|
155
|
+
service: "opscontext-event-ingest",
|
|
156
|
+
port: PORT,
|
|
157
|
+
secretConfigured: loadSecret() !== null,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function handleOptions(_req, res) {
|
|
161
|
+
// CORS preflight — the SW shouldn't need this thanks to host_permissions,
|
|
162
|
+
// but answering it cleanly costs nothing and helps popup probes.
|
|
163
|
+
res.writeHead(204, {
|
|
164
|
+
"Access-Control-Allow-Origin": "*",
|
|
165
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
166
|
+
"Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
|
|
167
|
+
"Access-Control-Max-Age": "600",
|
|
168
|
+
});
|
|
169
|
+
res.end();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Boot the local event-ingest HTTP server. Returns the listening port or null
|
|
173
|
+
* if the port was already in use (caller may decide whether to retry on
|
|
174
|
+
* another port or surface the error).
|
|
175
|
+
*
|
|
176
|
+
* Safe to call multiple times — second call returns the existing server.
|
|
177
|
+
*/
|
|
178
|
+
export function startEventIngestServer() {
|
|
179
|
+
if (serverInstance) {
|
|
180
|
+
const addr = serverInstance.address();
|
|
181
|
+
return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
|
|
182
|
+
}
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
const srv = http.createServer((req, res) => {
|
|
185
|
+
try {
|
|
186
|
+
if (req.method === "OPTIONS")
|
|
187
|
+
return handleOptions(req, res);
|
|
188
|
+
const url = req.url || "/";
|
|
189
|
+
if (req.method === "POST" && url.startsWith("/events"))
|
|
190
|
+
return handleEvents(req, res);
|
|
191
|
+
if (req.method === "GET" && url.startsWith("/health"))
|
|
192
|
+
return handleHealth(req, res);
|
|
193
|
+
sendJson(res, 404, { ok: false, error: "not_found" });
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
console.error("[ContextEngine] event-ingest error:", err);
|
|
197
|
+
try {
|
|
198
|
+
sendJson(res, 500, { ok: false, error: "internal" });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* ignore — response may already be closed */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
srv.on("error", (err) => {
|
|
206
|
+
if (err.code === "EADDRINUSE") {
|
|
207
|
+
console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest disabled.\n` +
|
|
208
|
+
` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
|
|
209
|
+
resolve(null);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
console.error("[ContextEngine] event-ingest server error:", err);
|
|
213
|
+
resolve(null);
|
|
214
|
+
});
|
|
215
|
+
srv.listen(PORT, HOST, () => {
|
|
216
|
+
serverInstance = srv;
|
|
217
|
+
console.error(`[ContextEngine] 🌐 event-ingest on http://${HOST}:${PORT} ` +
|
|
218
|
+
(loadSecret() ? "(secret loaded)" : "(NO SECRET — run `contextengine init-extension-secret`)"));
|
|
219
|
+
resolve(PORT);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export function stopEventIngestServer() {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
if (!serverInstance)
|
|
226
|
+
return resolve();
|
|
227
|
+
serverInstance.close(() => {
|
|
228
|
+
serverInstance = null;
|
|
229
|
+
resolve();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// Test helpers (not exported in dist surface in production use — but the
|
|
234
|
+
// module is small enough that tests can import them directly).
|
|
235
|
+
export const _internal = {
|
|
236
|
+
loadSecret,
|
|
237
|
+
constantTimeEqual,
|
|
238
|
+
validateEvent,
|
|
239
|
+
SECRET_FILE,
|
|
240
|
+
PORT,
|
|
241
|
+
};
|
|
242
|
+
//# sourceMappingURL=http-server.js.map
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ import { loadCache, saveCache } from "./cache.js";
|
|
|
11
11
|
import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, } from "./agents.js";
|
|
12
12
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
13
13
|
import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
|
|
14
|
+
import { startEventIngestServer } from "./http-server.js";
|
|
15
|
+
import { detect } from "./detector.js";
|
|
14
16
|
import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
|
|
15
17
|
import { readFileSync, existsSync, watch, statSync } from "fs";
|
|
16
18
|
import { basename, join, dirname } from "path";
|
|
@@ -580,6 +582,39 @@ server.tool("audit_verify", "Verify the integrity of the local audit log chain.
|
|
|
580
582
|
return respond("audit_verify", summary.join("\n"));
|
|
581
583
|
});
|
|
582
584
|
// ---------------------------------------------------------------------------
|
|
585
|
+
// Tool: drift_status (Detector — read current drift signals)
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// Agents should call this between major task phases. If any 'critical' signal
|
|
588
|
+
// is active, they should pause and surface to the human. The signals are also
|
|
589
|
+
// appended to the audit log as drift.detected events so post-hoc review can
|
|
590
|
+
// reconstruct what fired and when.
|
|
591
|
+
server.tool("drift_status", "Returns active drift / loop / stuck-tool / fabrication / silent-failure signals detected over the recent audit-log window. Use to self-check before starting a major task phase. If any 'critical' signal is active (fabrication_suspect or silent_failure), pause and surface to the human.", {
|
|
592
|
+
windowSeconds: z.number().optional().describe("Look-back window in seconds. Default 300 (5 min)."),
|
|
593
|
+
minSeverity: z.enum(["info", "warn", "critical"]).optional().describe("Floor filter for severity. Default 'info' (everything)."),
|
|
594
|
+
}, async ({ windowSeconds, minSeverity }) => {
|
|
595
|
+
const signals = detect({ windowSeconds: windowSeconds ?? 300 });
|
|
596
|
+
const order = { info: 0, warn: 1, critical: 2 };
|
|
597
|
+
const floor = order[minSeverity ?? "info"];
|
|
598
|
+
const filtered = signals.filter((s) => order[s.severity] >= floor);
|
|
599
|
+
const lines = [];
|
|
600
|
+
lines.push(`Drift signals: ${filtered.length} active (window=${windowSeconds ?? 300}s, minSeverity=${minSeverity ?? "info"}).`);
|
|
601
|
+
if (filtered.length === 0) {
|
|
602
|
+
lines.push("All clear.");
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
for (const s of filtered) {
|
|
606
|
+
const sev = s.severity.toUpperCase();
|
|
607
|
+
lines.push(` [${sev}] ${s.kind}: ${s.reason}`);
|
|
608
|
+
}
|
|
609
|
+
const critical = filtered.filter((s) => s.severity === "critical");
|
|
610
|
+
if (critical.length > 0) {
|
|
611
|
+
lines.push("");
|
|
612
|
+
lines.push(`⛔ ${critical.length} CRITICAL signal(s) — pause the task and surface to the human.`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return respond("drift_status", lines.join("\n"));
|
|
616
|
+
});
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
583
618
|
// Tool: end_session (End-of-Session Protocol Enforcer)
|
|
584
619
|
// ---------------------------------------------------------------------------
|
|
585
620
|
server.tool("end_session", "MUST be called before ending any coding session. Checks all project repos for uncommitted changes, verifies documentation freshness (copilot-instructions.md, SKILLS.md, session docs), and returns a checklist of required actions. Will report PASS/FAIL for each check. The AI agent should resolve all FAIL items before ending.", {}, async () => {
|
|
@@ -1073,6 +1108,15 @@ async function main() {
|
|
|
1073
1108
|
}
|
|
1074
1109
|
// 5. Start file watchers
|
|
1075
1110
|
startWatching();
|
|
1111
|
+
// 6. Boot the local HTTP event-ingest endpoint for the browser extension.
|
|
1112
|
+
// Local 127.0.0.1:7842 only; auth via shared secret at
|
|
1113
|
+
// ~/.contextengine/extension-secret (see init-extension-secret CLI).
|
|
1114
|
+
// No-op if secret is missing; the endpoint will refuse with 401 until
|
|
1115
|
+
// a secret is configured. Failure to bind (port collision) logs and
|
|
1116
|
+
// continues — the MCP server stays usable without browser capture.
|
|
1117
|
+
startEventIngestServer().catch((err) => {
|
|
1118
|
+
console.error("[ContextEngine] event-ingest start failed:", err);
|
|
1119
|
+
});
|
|
1076
1120
|
}
|
|
1077
1121
|
main().catch((err) => {
|
|
1078
1122
|
console.error("[ContextEngine] Fatal:", err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|