@compr/opscontext-mcp 2.8.4 → 2.9.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/dist/cli.js CHANGED
@@ -651,7 +651,8 @@ import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLea
651
651
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
652
652
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
653
653
  import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
654
- import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, } from "./audit.js";
654
+ import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, } from "./audit.js";
655
+ import { redactPayload } from "./secret-shapes.js";
655
656
  import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
656
657
  import { buildCostReport } from "./cost-report.js";
657
658
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
@@ -1996,6 +1997,68 @@ function cliAuditRotate(args) {
1996
1997
  process.exit(2);
1997
1998
  }
1998
1999
  }
2000
+ /** Remove credentials from records already written. [LOCK] [SCRUB-IS-ACKNOWLEDGED-REDACTION] */
2001
+ function cliAuditScrub(args) {
2002
+ const reasonAt = args.indexOf("--reason");
2003
+ const reason = reasonAt >= 0 ? args[reasonAt + 1] ?? "" : "";
2004
+ const apply = args.includes("--apply");
2005
+ const r = scrubAuditLog({ apply, reason, redact: redactPayload, actor: "cli" });
2006
+ if (r.refusedReason) {
2007
+ console.error(`\nNot scrubbed: ${r.refusedReason}\n`);
2008
+ process.exit(1);
2009
+ }
2010
+ const hit = r.files.filter((f) => f.redacted > 0);
2011
+ console.log(`\nAudit scrub${apply ? "" : ", DRY RUN, nothing written"}\n`);
2012
+ console.log(` records to redact ${r.redactedRecords}${apply ? " (redacted)" : ""}, in ${hit.length} of ${r.files.length} file(s)`);
2013
+ for (const [id, n] of Object.entries(r.counts).sort((a, b) => b[1] - a[1])) {
2014
+ console.log(` ${id.padEnd(24)} ${n}`);
2015
+ }
2016
+ if (!apply) {
2017
+ if (r.redactedRecords > 0)
2018
+ console.log(`\n Run again with --apply --reason "<why>" to rewrite them and acknowledge it on the chain.\n`);
2019
+ return;
2020
+ }
2021
+ console.log(` acknowledged by ${r.acknowledgements.length} audit.redact record(s)`);
2022
+ const after = verifyChain();
2023
+ console.log(`\n audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${after.total} record(s), ` +
2024
+ `${(after.redactedIndices ?? []).length} redacted and acknowledged, ${(after.tamperedIndices ?? []).length} altered.\n`);
2025
+ if (!after.ok)
2026
+ process.exit(2);
2027
+ }
2028
+ /** Put a lost block of audit records back from a backup. [LOCK] [RESTORE-ONLY-CLOSES-A-PROVEN-GAP] */
2029
+ function cliAuditRestore(args) {
2030
+ const reasonAt = args.indexOf("--reason");
2031
+ const reason = reasonAt >= 0 ? args[reasonAt + 1] ?? "" : "";
2032
+ const apply = args.includes("--apply");
2033
+ const file = args.find((a, i) => !a.startsWith("--") && !(reasonAt >= 0 && i === reasonAt + 1));
2034
+ if (!file) {
2035
+ console.error(`usage: contextengine audit-restore <file.jsonl> [--apply --reason "<where the records came from and why>"]`);
2036
+ console.error(` Without --apply it only checks the file and says what it would do.`);
2037
+ process.exit(1);
2038
+ }
2039
+ const r = restoreSegment(resolve(file), { apply, reason, actor: "cli" });
2040
+ if (r.refusedReason) {
2041
+ console.error(`\nNot restored: ${r.refusedReason}\n`);
2042
+ process.exit(1);
2043
+ }
2044
+ console.log(`\nAudit restore${apply ? "" : ", DRY RUN, nothing written"}\n`);
2045
+ console.log(` block ${r.records} record(s), ${r.firstTs} to ${r.lastTs}`);
2046
+ console.log(` every record matches its own hash`);
2047
+ console.log(` fits between ${r.after} and ${r.before}`);
2048
+ console.log(` closes the orphan at index ${r.orphanIndex}`);
2049
+ if (!apply) {
2050
+ console.log(` would write audit-archive/${r.segmentFile}`);
2051
+ console.log(`\n Run again with --apply --reason "<where the records came from and why>" to write it.\n`);
2052
+ return;
2053
+ }
2054
+ console.log(` wrote audit-archive/${r.segmentFile}`);
2055
+ console.log(` recorded as audit.restore, hash ${r.record.hash.slice(0, 16)}...`);
2056
+ const after = verifyChain();
2057
+ console.log(`\n audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${after.total} record(s), ` +
2058
+ `${(after.orphanIndices ?? []).length} orphan(s), ${(after.tamperedIndices ?? []).length} altered.\n`);
2059
+ if (!after.ok)
2060
+ process.exit(2);
2061
+ }
1999
2062
  /** Acknowledge deliberately redacted audit records on the chain. [LOCK] [REDACTION-IS-A-CHAINED-RECORD] */
2000
2063
  function cliAuditRedactAck(args) {
2001
2064
  const idxAt = args.indexOf("--index");
@@ -2058,6 +2121,8 @@ async function cliAuditVerify() {
2058
2121
  console.error(`\n Orphaned records (parent hash absent from the log):`);
2059
2122
  console.error(` ${o.slice(0, 10).join(", ")}${o.length > 10 ? `, … (+${o.length - 10} more)` : ""}`);
2060
2123
  console.error(` This means earlier records were deleted or the log was truncated.`);
2124
+ console.error(` If a backup holds the missing records, 'contextengine audit-restore <file>' checks`);
2125
+ console.error(` that they fill this gap exactly, and puts them back with --apply.`);
2061
2126
  }
2062
2127
  if (forks.length > 0) {
2063
2128
  console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
@@ -2552,6 +2617,14 @@ Usage:
2552
2617
  the size ceiling (default 50000 live records),
2553
2618
  whichever is more. The chain stays linear: segments
2554
2619
  are part of the verified history, never deleted.
2620
+ contextengine audit-restore <file> [--apply --reason "..."]
2621
+ Put a lost block of records back from a backup. Only
2622
+ fills a gap audit-verify reports, at a segment boundary,
2623
+ after checking every record's hash. Dry run by default.
2624
+ contextengine audit-scrub [--apply --reason "..."]
2625
+ Remove credentials (keys, tokens, passwords in commands
2626
+ and URLs) from captured records already written, and
2627
+ acknowledge each rewrite on the chain. Dry run by default.
2555
2628
  contextengine cost [--session ID] [--project NAME] [--run wf_ID] [--days N] [--top N] [--json]
2556
2629
  Multi-agent spend from Claude Code transcripts. Always prints
2557
2630
  VOLUME (tokens), VALUED COST (API list prices — notional on a
@@ -2757,6 +2830,12 @@ else if (command === "audit-redact-ack") {
2757
2830
  else if (command === "audit-rotate") {
2758
2831
  cliAuditRotate(process.argv.slice(3));
2759
2832
  }
2833
+ else if (command === "audit-restore") {
2834
+ cliAuditRestore(process.argv.slice(3));
2835
+ }
2836
+ else if (command === "audit-scrub") {
2837
+ cliAuditScrub(process.argv.slice(3));
2838
+ }
2760
2839
  else if (command === "servers") {
2761
2840
  const fleet = listServers();
2762
2841
  const health = computeFleetHealth({ version: readPackageVersion(), report: fleet });
package/dist/detector.js CHANGED
@@ -79,7 +79,9 @@ function stableStringify(v) {
79
79
  }
80
80
  function getText(r) {
81
81
  const p = r.payload || {};
82
- return String(p.text ?? p.preview ?? p.args_preview ?? "");
82
+ // A prompt kept as a fingerprint only ([PROMPT-TEXT-IS-NOT-KEPT]) compares as one token: equal
83
+ // for an exact repeat, different otherwise.
84
+ return String(p.text ?? p.text_fingerprint ?? p.preview ?? p.args_preview ?? "");
83
85
  }
84
86
  function sessionKey(r) {
85
87
  const p = r.payload || {};
@@ -120,7 +122,7 @@ function detectLoop(events) {
120
122
  }
121
123
  }
122
124
  if (dupes >= 2) {
123
- const snippet = getText(prompts[i]).slice(0, 80);
125
+ const snippet = typeof prompts[i].payload?.text === "string" ? getText(prompts[i]).slice(0, 80) : "(prompt text not kept)";
124
126
  return mk("loop", "warn", `Same prompt sent ${dupes + 1}× in 5 min`, matches, { repetitions: dupes + 1, snippet });
125
127
  }
126
128
  }
@@ -188,7 +190,9 @@ function detectFabrication(events, cwd) {
188
190
  function detectDrift(events) {
189
191
  // Per-session: if the last 3 prompts are jointly far (low overlap) from
190
192
  // the session's FIRST prompt, the conversation has drifted off-topic.
191
- const bySession = groupBy(events.filter((e) => e.event === "browser.prompt" || e.event === "vscode.prompt_submit"), sessionKey);
193
+ // Word overlap needs words: prompts kept as a fingerprint only are left out, or every session
194
+ // would read as drifted. [LOCK] [PROMPT-TEXT-IS-NOT-KEPT]
195
+ const bySession = groupBy(events.filter((e) => (e.event === "browser.prompt" || e.event === "vscode.prompt_submit") && typeof e.payload?.text === "string"), sessionKey);
192
196
  for (const [sid, prompts] of bySession) {
193
197
  if (prompts.length < 4)
194
198
  continue;
@@ -10,6 +10,12 @@ declare function loadSecret(): string | null;
10
10
  declare function constantTimeEqual(a: string, b: string): boolean;
11
11
  /** Validate an event has the minimum shape we'll write to audit. */
12
12
  declare function validateEvent(e: IncomingEvent, idx: number): string | null;
13
+ /**
14
+ * The payload as it will be written: every string redacted, and the per-shape counts recorded
15
+ * under `redacted_at_ingest` when anything was removed. The only path from the network to the
16
+ * log. [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR] (src/secret-shapes.ts)
17
+ */
18
+ export declare function prepareCapturedPayload(payload: Record<string, unknown>, event?: string): Record<string, unknown>;
13
19
  /**
14
20
  * Boot the local event-ingest HTTP server. Returns the listening port or null
15
21
  * if the port was already in use (caller may decide whether to retry on
@@ -20,6 +26,7 @@ declare function validateEvent(e: IncomingEvent, idx: number): string | null;
20
26
  export declare function startEventIngestServer(): Promise<number | null>;
21
27
  export declare function stopEventIngestServer(): Promise<void>;
22
28
  export declare const _internal: {
29
+ prepareCapturedPayload: typeof prepareCapturedPayload;
23
30
  loadSecret: typeof loadSecret;
24
31
  constantTimeEqual: typeof constantTimeEqual;
25
32
  validateEvent: typeof validateEvent;
@@ -23,6 +23,7 @@ import { join } from "path";
23
23
  import { homedir } from "os";
24
24
  import { timingSafeEqual } from "crypto";
25
25
  import { safeAppend } from "./audit.js";
26
+ import { redactPayload, textFingerprint } from "./secret-shapes.js";
26
27
  const PORT = parseInt(process.env.OPSCONTEXT_EVENT_PORT || "7842", 10);
27
28
  const HOST = "127.0.0.1";
28
29
  const SECRET_FILE = join(homedir(), ".contextengine", "extension-secret");
@@ -73,6 +74,40 @@ function validateEvent(e, idx) {
73
74
  }
74
75
  return null;
75
76
  }
77
+ /**
78
+ * The payload as it will be written: every string redacted, and the per-shape counts recorded
79
+ * under `redacted_at_ingest` when anything was removed. The only path from the network to the
80
+ * log. [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR] (src/secret-shapes.ts)
81
+ */
82
+ export function prepareCapturedPayload(payload, event) {
83
+ const { value, counts, changed } = redactPayload(dropPromptText(payload, event));
84
+ return changed ? { ...value, redacted_at_ingest: counts } : value;
85
+ }
86
+ /**
87
+ * [LOCKED] [PROMPT-TEXT-IS-NOT-KEPT] - 2026-09-25
88
+ * [NEVER] write the text of a prompt or an AI response to the audit log by default.
89
+ * WHY: the owner's decision, 2026-09-25. The text fed one feature, the loop and drift alarm, which
90
+ * fired 0 times in three months of real use; meanwhile it held the owner's typed passwords
91
+ * and logins. A record of "prompt sent at 10:02, 340 characters" keeps the evidence of
92
+ * activity without keeping what was said.
93
+ * FIX: prompts (vscode.prompt_submit, browser.prompt) and responses (browser.response) keep their
94
+ * length and a keyed fingerprint (textFingerprint), so an exact repeat is still detectable.
95
+ * Commands and tool input stay, redacted: they are the record of what an agent did.
96
+ * OPSCONTEXT_KEEP_PROMPT_TEXT=1 keeps the (redacted) text, for someone who wants it.
97
+ */
98
+ const PROMPT_TEXT_EVENTS = new Set(["vscode.prompt_submit", "browser.prompt", "browser.response"]);
99
+ function dropPromptText(payload, event) {
100
+ if (!event || !PROMPT_TEXT_EVENTS.has(event) || process.env.OPSCONTEXT_KEEP_PROMPT_TEXT === "1")
101
+ return payload;
102
+ if (typeof payload.text !== "string")
103
+ return payload;
104
+ const { text, ...rest } = payload;
105
+ return {
106
+ ...rest,
107
+ char_count: typeof rest.char_count === "number" ? rest.char_count : text.length,
108
+ text_fingerprint: textFingerprint(text),
109
+ };
110
+ }
76
111
  function sendJson(res, status, body) {
77
112
  const json = JSON.stringify(body);
78
113
  res.writeHead(status, {
@@ -143,7 +178,8 @@ function handleEvents(req, res) {
143
178
  for (const ev of batch.events) {
144
179
  const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
145
180
  // event/payload were validated above — cast is safe.
146
- safeAppend(ev.event, ev.payload, actor);
181
+ // [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR]: redact before the append, never after.
182
+ safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor);
147
183
  written++;
148
184
  }
149
185
  sendJson(res, 200, { ok: true, written });
@@ -233,6 +269,7 @@ export function stopEventIngestServer() {
233
269
  // Test helpers (not exported in dist surface in production use — but the
234
270
  // module is small enough that tests can import them directly).
235
271
  export const _internal = {
272
+ prepareCapturedPayload,
236
273
  loadSecret,
237
274
  constantTimeEqual,
238
275
  validateEvent,
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
6
6
  import { ingestSources } from "./ingest.js";
7
+ import { redactSecrets } from "./secret-shapes.js";
7
8
  import { summarizeSource } from "./source-summary.js";
8
9
  import { searchChunks } from "./search.js";
9
10
  import { initEmbeddings, embedChunks, embedKeyOf, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
@@ -152,6 +153,8 @@ async function buildIndex(opts) {
152
153
  console.error(`[ContextEngine] 🌐 Injected ${added} community rule chunks ` +
153
154
  `(${skipped} dedup'd vs local; ${store.rules.length} total in cache)`);
154
155
  }
156
+ // [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: before the adapters, whose block can return early.
157
+ chunks = chunks.map(redactChunk);
155
158
  // Collect from plugin adapters
156
159
  if (config.adapters && config.adapters.length > 0) {
157
160
  if (opts.loadAdapters) {
@@ -161,11 +164,27 @@ async function buildIndex(opts) {
161
164
  }
162
165
  const adapterChunks = await collectFromAdapters(config.adapters);
163
166
  if (adapterChunks.length > 0) {
164
- chunks.push(...adapterChunks);
167
+ chunks.push(...adapterChunks.map(redactChunk));
165
168
  console.error(`[ContextEngine] 🔌 Adapters contributed ${adapterChunks.length} chunks`);
166
169
  }
167
170
  }
168
171
  }
172
+ /**
173
+ * [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25
174
+ * [NEVER] let a chunk into the index, the shared index file or a search result without passing
175
+ * its text through redactSecrets().
176
+ * WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
177
+ * files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
178
+ * runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
179
+ * chunks to every AI agent that asks, and the index file rides the weekly backup.
180
+ * FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
181
+ * adapters), is redacted with the capture shapes (src/secret-shapes.ts) as the index is built.
182
+ * The source files are not touched: cleaning those is the owner's call, file by file.
183
+ */
184
+ function redactChunk(c) {
185
+ const r = redactSecrets(c.content);
186
+ return Object.keys(r.counts).length > 0 ? { ...c, content: r.text } : c;
187
+ }
169
188
  /** Load the model once; every caller shares the same promise. */
170
189
  function ensureModel() {
171
190
  if (!modelInit)
package/dist/rubric.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * WHAT THIS IS NOT: hidden behaviour. This file contains numbers and nothing else — no network
10
10
  * calls, no file access, no data collection. Every code path in this package ships readable,
11
11
  * including the two (and only two) network calls the tool ever makes: licence activation and the
12
- * daily heartbeat, both in activation.js. Full source: https://github.com/FASTPROD/ContextEngine
12
+ * daily heartbeat, both in activation.js. Full source: https://github.com/FASTPROD/opscontext-mcp
13
13
  *
14
14
  * Your project data never leaves your machine. See the Privacy section of the README, and verify
15
15
  * it in the source rather than taking our word for it.
@@ -0,0 +1,28 @@
1
+ export interface SecretShape {
2
+ id: string;
3
+ re: RegExp;
4
+ /** Group 1 is context to keep (a flag, a key name, `scheme://user:`); the rest is replaced. */
5
+ keepPrefix?: boolean;
6
+ /** Return true to leave a match alone (a function call, a type name). */
7
+ skip?: (value: string, after: string) => boolean;
8
+ }
9
+ export declare const SECRET_SHAPES: SecretShape[];
10
+ /**
11
+ * Keyed fingerprint of a text: HMAC-SHA-256 with a key that never leaves this machine, first 16
12
+ * hex characters. Two identical prompts get the same fingerprint, so exact repeats stay
13
+ * detectable; without the key a short prompt ("yes", "go on") cannot be guessed back from it.
14
+ */
15
+ export declare function textFingerprint(text: string): string;
16
+ export interface RedactionResult {
17
+ text: string;
18
+ counts: Record<string, number>;
19
+ }
20
+ /** Replace every credential shape in `input`. Returns the new text and the count per shape. */
21
+ export declare function redactSecrets(input: string): RedactionResult;
22
+ /** Redact every string inside a JSON-like value, returning a copy and the merged counts. */
23
+ export declare function redactPayload<T>(value: T): {
24
+ value: T;
25
+ counts: Record<string, number>;
26
+ changed: boolean;
27
+ };
28
+ //# sourceMappingURL=secret-shapes.d.ts.map
@@ -0,0 +1,132 @@
1
+ // [LOCKED] [CAPTURE-IS-REDACTED-AT-THE-DOOR] - 2026-09-25
2
+ // [NEVER] write captured text (prompts, commands, tool input, errors) to the audit log without
3
+ // passing it through redactPayload(), and never narrow these shapes without replaying
4
+ // them against a real log first.
5
+ // WHY: until 2.8.4 the Claude Code hook sent the first 4,000 characters of every prompt and
6
+ // the first 200 of every command straight into audit.log; only the browser extension
7
+ // filtered. A scan on 2026-09-24 found credentials that had sat there for three months:
8
+ // a Stripe live key, production database passwords inside connection URLs, SSH passwords
9
+ // after `sshpass -p`, API tokens after `Bearer`. The log is hash-chained and archived for
10
+ // good, so a secret written once stays forever unless a redaction is acknowledged.
11
+ // FIX: every capture event goes through one door, POST /events in src/http-server.ts, and that
12
+ // door redacts every string of the payload with the shapes below before the append. The
13
+ // same shapes drive `contextengine audit-scrub`, which cleans records written before this.
14
+ // The hook's cut at 200 characters can split a secret; a fragment under a shape's minimum
15
+ // length is not caught. A password written in plain words is not caught either.
16
+ //
17
+ import { createHmac, randomBytes } from "crypto";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
19
+ import { join } from "path";
20
+ import { homedir } from "os";
21
+ const QUOTED_OR_BARE = String.raw `(?:'[^']*'|"[^"]*"|[^\s'"]+)`;
22
+ export const SECRET_SHAPES = [
23
+ { id: "private_key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g },
24
+ { id: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
25
+ { id: "aws_secret_key", re: /(aws_secret_access_key["']?\s*[:=]\s*["']?)[A-Za-z0-9/+=]{40}/gi, keepPrefix: true },
26
+ { id: "stripe_key", re: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}/g },
27
+ { id: "stripe_webhook_secret", re: /\bwhsec_[0-9A-Za-z]{16,}/g },
28
+ { id: "github_token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[0-9A-Za-z]{30,}|\bgithub_pat_[0-9A-Za-z_]{40,}/g },
29
+ { id: "anthropic_key", re: /\bsk-ant-[0-9A-Za-z_-]{20,}/g },
30
+ { id: "openai_key", re: /\bsk-(?!ant-)(?:proj-|svcacct-|admin-)?[0-9A-Za-z_-]{32,}/g },
31
+ { id: "slack_token", re: /\bxox[abposr]-[0-9A-Za-z-]{10,}/g },
32
+ { id: "slack_webhook", re: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/g },
33
+ { id: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}/g },
34
+ { id: "npm_token", re: /\bnpm_[0-9A-Za-z]{36}\b/g },
35
+ { id: "sendgrid_key", re: /\bSG\.[0-9A-Za-z_-]{16,}\.[0-9A-Za-z_-]{16,}/g },
36
+ { id: "telegram_bot_token", re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/g },
37
+ { id: "jwt", re: /\beyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}/g },
38
+ // scheme://user:SECRET@host, the shape of every database URL found on 2026-09-24.
39
+ { id: "url_password", re: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@'"`]+:)[^\s@/'"`]+(?=@)/gi, keepPrefix: true },
40
+ { id: "sshpass_password", re: new RegExp(String.raw `(\bsshpass\s+-p\s*)${QUOTED_OR_BARE}`, "g"), keepPrefix: true },
41
+ { id: "sshpass_env", re: new RegExp(String.raw `(\bSSHPASS=)${QUOTED_OR_BARE}`, "g"), keepPrefix: true },
42
+ // mysql -pSECRET (the value is glued to the flag; a bare -p prompts and is left alone).
43
+ { id: "mysql_password", re: new RegExp(String.raw `(\bmysql(?:dump|admin)?\b[^\n]*?\s-p)(?=[^\s-])${QUOTED_OR_BARE}`, "g"), keepPrefix: true },
44
+ { id: "password_flag", re: new RegExp(String.raw `(--password[=\s]\s*)${QUOTED_OR_BARE}`, "gi"), keepPrefix: true },
45
+ { id: "bearer_token", re: /(\bbearer\s+)(?=[0-9A-Za-z._~+/-]{20,})[0-9A-Za-z._~+/-]+=*/gi, keepPrefix: true },
46
+ { id: "basic_auth", re: /(\bauthorization:\s*basic\s+)[A-Za-z0-9+/]{8,}=*/gi, keepPrefix: true },
47
+ { id: "curl_user_password", re: /(\bcurl\b[^\n]*?\s(?:-u|--user)\s*['"]?[^\s:'"]+:)[^\s'"]+/g, keepPrefix: true },
48
+ { id: "api_key_header", re: /(\bx-api-key:\s*)[^\s'"]{8,}/gi, keepPrefix: true },
49
+ // name = value, the long tail. Skips variables, env lookups, paths, placeholders already
50
+ // redacted, type names and function calls.
51
+ {
52
+ id: "credential_assignment",
53
+ // A backtick opens a value too: "password: `...`" in Markdown, found in the real log.
54
+ re: /(\b[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
55
+ keepPrefix: true,
56
+ skip: (value, after) => after.startsWith("(") ||
57
+ (/^[A-Za-z_$][\w$.]*\(/.test(value) && after.startsWith(")")) || // getToken(user), not a value
58
+ /^(?:string|number|boolean|null|undefined|true|false|none|str|int|bool|any|object|required|optional)$/i.test(value),
59
+ },
60
+ ];
61
+ /**
62
+ * Keyed fingerprint of a text: HMAC-SHA-256 with a key that never leaves this machine, first 16
63
+ * hex characters. Two identical prompts get the same fingerprint, so exact repeats stay
64
+ * detectable; without the key a short prompt ("yes", "go on") cannot be guessed back from it.
65
+ */
66
+ export function textFingerprint(text) {
67
+ return createHmac("sha256", fingerprintKey()).update(text).digest("hex").slice(0, 16);
68
+ }
69
+ let cachedKey = null;
70
+ function fingerprintKey() {
71
+ const dir = join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "keys");
72
+ const path = join(dir, "fingerprint.key");
73
+ if (cachedKey?.path === path)
74
+ return cachedKey.key;
75
+ if (!existsSync(path)) {
76
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
77
+ try {
78
+ writeFileSync(path, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
79
+ }
80
+ catch {
81
+ /* another process created it first: read theirs */
82
+ }
83
+ }
84
+ cachedKey = { path, key: Buffer.from(readFileSync(path, "utf-8").trim(), "hex") };
85
+ return cachedKey.key;
86
+ }
87
+ /** Replace every credential shape in `input`. Returns the new text and the count per shape. */
88
+ export function redactSecrets(input) {
89
+ const counts = {};
90
+ let text = input;
91
+ for (const shape of SECRET_SHAPES) {
92
+ shape.re.lastIndex = 0;
93
+ text = text.replace(shape.re, (...args) => {
94
+ const match = args[0];
95
+ const offset = args[args.length - 2];
96
+ const whole = args[args.length - 1];
97
+ const prefix = shape.keepPrefix ? String(args[1] ?? "") : "";
98
+ const value = match.slice(prefix.length);
99
+ if (value.startsWith("[REDACTED:"))
100
+ return match; // already done: keeps a second pass a no-op
101
+ if (shape.skip?.(value, whole.slice(offset + match.length)))
102
+ return match;
103
+ counts[shape.id] = (counts[shape.id] ?? 0) + 1;
104
+ return `${prefix}[REDACTED:${shape.id}]`;
105
+ });
106
+ }
107
+ return { text, counts };
108
+ }
109
+ /** Redact every string inside a JSON-like value, returning a copy and the merged counts. */
110
+ export function redactPayload(value) {
111
+ const counts = {};
112
+ const walk = (v) => {
113
+ if (typeof v === "string") {
114
+ const r = redactSecrets(v);
115
+ for (const [k, n] of Object.entries(r.counts))
116
+ counts[k] = (counts[k] ?? 0) + n;
117
+ return r.text;
118
+ }
119
+ if (Array.isArray(v))
120
+ return v.map(walk);
121
+ if (v && typeof v === "object") {
122
+ const out = {};
123
+ for (const [k, x] of Object.entries(v))
124
+ out[k] = walk(x);
125
+ return out;
126
+ }
127
+ return v;
128
+ };
129
+ const out = walk(value);
130
+ return { value: out, counts, changed: Object.keys(counts).length > 0 };
131
+ }
132
+ //# sourceMappingURL=secret-shapes.js.map
@@ -25,7 +25,17 @@ export interface ServerReport {
25
25
  }
26
26
  /** More concurrent servers than this and every doc change costs that many re-embeds. */
27
27
  export declare const SERVER_COUNT_WARN = 3;
28
- /** Short content hash of the script a server loaded; the build identity. */
28
+ /**
29
+ * Short content hash of the build a server loaded: every `.js` file next to its entry script.
30
+ *
31
+ * [LOCKED] [BUILD-HASH-COVERS-EVERY-MODULE] - 2026-09-25
32
+ * [NEVER] fingerprint a build by its entry file alone.
33
+ * WHY: the hash used to cover dist/index.js only. The 2026-09-24 audit fix changed audit.js and
34
+ * cli.js, index.js stayed byte-identical, and `contextengine servers` reported "0 on an old
35
+ * build" for 29 servers that were still running the code that had erased archived history.
36
+ * FIX: hash the name and content of every .js file in the entry's folder, in name order. A
37
+ * script outside such a folder falls back to its own content.
38
+ */
29
39
  export declare function buildHashOf(scriptPath: string): string | null;
30
40
  export declare function isAlive(pid: number): boolean;
31
41
  /**
@@ -12,8 +12,8 @@
12
12
  // goes on exit, and a lister removes records whose pid is dead. `contextengine servers`
13
13
  // and the end-session checklist compare each record's build hash with the file on disk
14
14
  // now, and warn when more than SERVER_COUNT_WARN servers run at once.
15
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
16
- import { join } from "path";
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs";
16
+ import { basename, dirname, join } from "path";
17
17
  import { homedir } from "os";
18
18
  import { createHash } from "crypto";
19
19
  import { execFileSync } from "child_process";
@@ -23,15 +23,40 @@ const HEARTBEAT_MS = 60_000;
23
23
  function registryDir() {
24
24
  return join(process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine"), "servers");
25
25
  }
26
- /** Short content hash of the script a server loaded; the build identity. */
26
+ /**
27
+ * Short content hash of the build a server loaded: every `.js` file next to its entry script.
28
+ *
29
+ * [LOCKED] [BUILD-HASH-COVERS-EVERY-MODULE] - 2026-09-25
30
+ * [NEVER] fingerprint a build by its entry file alone.
31
+ * WHY: the hash used to cover dist/index.js only. The 2026-09-24 audit fix changed audit.js and
32
+ * cli.js, index.js stayed byte-identical, and `contextengine servers` reported "0 on an old
33
+ * build" for 29 servers that were still running the code that had erased archived history.
34
+ * FIX: hash the name and content of every .js file in the entry's folder, in name order. A
35
+ * script outside such a folder falls back to its own content.
36
+ */
27
37
  export function buildHashOf(scriptPath) {
28
38
  try {
29
- return createHash("sha256").update(readFileSync(scriptPath)).digest("hex").slice(0, 12);
39
+ const dir = dirname(scriptPath);
40
+ const files = readdirSync(dir).filter((f) => f.endsWith(".js")).sort();
41
+ if (!files.includes(basename(scriptPath))) {
42
+ return createHash("sha256").update(readFileSync(scriptPath)).digest("hex").slice(0, 12);
43
+ }
44
+ const signature = files.map((f) => { const s = statSync(join(dir, f)); return `${f}:${s.size}:${s.mtimeMs}`; }).join("|");
45
+ const cached = buildHashCache.get(dir);
46
+ if (cached && cached.signature === signature)
47
+ return cached.hash;
48
+ const h = createHash("sha256");
49
+ for (const f of files)
50
+ h.update(f).update("\0").update(readFileSync(join(dir, f))).update("\0");
51
+ const hash = h.digest("hex").slice(0, 12);
52
+ buildHashCache.set(dir, { signature, hash });
53
+ return hash;
30
54
  }
31
55
  catch {
32
56
  return null;
33
57
  }
34
58
  }
59
+ const buildHashCache = new Map();
35
60
  function parentName(ppid) {
36
61
  try {
37
62
  // Hardcoded argv, no shell: the only variable is a number.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.8.4",
3
+ "version": "2.9.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",
@@ -47,12 +47,12 @@
47
47
  "author": {
48
48
  "name": "PROD LLC (operating brand of CSS LLC, Swiss, est. 2005)",
49
49
  "email": "yannick@compr.ch",
50
- "url": "https://github.com/FASTPROD/ContextEngine/blob/main/docs/about.md"
50
+ "url": "https://github.com/FASTPROD/opscontext-mcp/blob/main/docs/about.md"
51
51
  },
52
52
  "license": "BSL-1.1",
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "https://github.com/FASTPROD/ContextEngine.git"
55
+ "url": "https://github.com/FASTPROD/opscontext-mcp.git"
56
56
  },
57
57
  "homepage": "https://compr.fr",
58
58
  "bugs": {