@compr/opscontext-mcp 2.0.1 → 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 CHANGED
@@ -2,6 +2,79 @@
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
+
48
+ ## [2.0.2] — 2026-06-11 — HTML score report browser tab title → OpsContext
49
+
50
+ Tiny patch release. One change:
51
+ - `src/agents.ts` `generateScoreHTML()` — `<title>` tag changed from `ContextEngine Score Report` to `OpsContext Score Report`. Visible in the browser tab when a paying user (or the `score --html` CLI flag) generates the report. No functional change.
52
+
53
+ Why patch: cosmetic-only rename of a user-visible string. No new APIs, no contract changes, no behavior change for any caller.
54
+
55
+ Paired with the VS Code extension `0.8.1` release that adds the `OpsContext: Generate HTML Score Report (PRO)` command — together they close the visible-paid-feature gap from the 2.0.0 rebrand (advertised "HTML score reports — ✓" but no clickable path).
56
+
57
+ ## [2.0.1] — 2026-06-11 — Flag day reached early + pricing href fix
58
+
59
+ ### Changed
60
+ - **Legacy SHA-256 license signatures are now REJECTED** (the "flag day" originally scheduled for 2026-08-15). Brought forward to today because the production licence DB analysis showed the customer base is effectively empty — the only real active license belongs to the maintainer, who has already re-activated to Ed25519. Three other "active" rows in the DB are licenses that expired 2026-04-27 and are already failing the expiry check on every load. Bringing the flag day forward by ~10 weeks costs nothing real and removes the half-open security loophole sooner.
61
+ - `src/license-sig.ts` `verifyLicenseSignature()` — the 64-char hex branch now returns `{ ok: false, reason: "Legacy SHA-256 ... reactivate at ..." }` instead of `{ ok: true, mode: "legacy-grandfathered" }`.
62
+ - LOCK comment on the file updated: the flag-day-reached marker replaces the warning-about-grandfathering text.
63
+ - `src/activation.ts` `loadLicense()` — the `if (verify.mode === "legacy-grandfathered")` branch is now defensive dead code (kept since the union type still carries the variant for backward compat with audit-log records).
64
+ - `tests/license-sig.test.ts` — the test that previously asserted `mode: "legacy-grandfathered"` now asserts `ok: false` with a reactivation pointer in the reason. Test renamed `REJECTS legacy SHA-256 hex signature (flag day reached 2026-06-11)`.
65
+ - **Pricing URL href fix** in 5 source files. The user-facing strings referenced `https://compr.ch/contextengine/pricing` which returns 404; only `https://api.compr.ch/contextengine/pricing` resolves. Files: `src/activation.ts:145, 450`, `src/cli.ts:1882`, `src/index.ts:1132, 1170`.
66
+ - **`docs/deploy/ED25519_FLAG_DAY.md`** can now be archived — the action it scheduled was completed today. Leaving the file in place as a historical record.
67
+ - **`server/deploy.sh` hardened** (this was committed earlier as `bbeffe8`, but tying it to the release for context): pre-flight builds locally, smoke-tests `/health` after deploy and dies on broken state, uses the full PM2 path that actually works in non-interactive ssh shells. Supports `--dry-run`.
68
+
69
+ ### Why this is patch-level, not minor
70
+ The behaviour change is a tightening of a check that was documented as temporary from day one. The semver contract said legacy signatures would be rejected after the flag day; the date moved up because the situation allowed it. No new features. No removed APIs.
71
+
72
+ ### Notes
73
+ - 4 paying-customer rows in the activation DB; 1 is real and active (maintainer's enterprise license, already re-activated). The other 3 already expired April 27 and are non-active per the client's expiry check.
74
+ - Customer notification email (originally scheduled for 2026-08-08) skipped — would have been a no-op or a re-engagement note to the two `gmail.com` addresses whose licenses expired. Yannick can send re-engagement separately if he wants.
75
+
76
+
77
+
5
78
  ## [2.0.0] — 2026-06-10 — Strategic pivot + Claude Code native integration (A+B+C)
6
79
 
7
80
  ### Added (Claude Code integration — A + B + C from the rebrand backlog)
package/dist/agents.js CHANGED
@@ -1546,7 +1546,7 @@ export function generateScoreHTML(scores) {
1546
1546
  <head>
1547
1547
  <meta charset="UTF-8">
1548
1548
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1549
- <title>ContextEngine Score Report</title>
1549
+ <title>OpsContext Score Report</title>
1550
1550
  <style>
1551
1551
  :root {
1552
1552
  --bg: #0f172a; --surface: #1e293b; --border: #334155;
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
@@ -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.1",
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",