@compr/opscontext-mcp 2.8.3 → 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/CHANGELOG.md +83 -0
- package/README.md +17 -17
- package/defaults/claude-code-hook.sh +11 -3
- package/defaults/simplicity-gate.py +135 -0
- package/dist/audit.d.ts +109 -1
- package/dist/audit.js +485 -41
- package/dist/cli-commands.js +2 -0
- package/dist/cli.js +80 -1
- package/dist/detector.js +7 -3
- package/dist/fleet-health.d.ts +15 -0
- package/dist/fleet-health.js +31 -2
- package/dist/http-server.d.ts +7 -0
- package/dist/http-server.js +38 -1
- package/dist/index.js +20 -1
- package/dist/install-claude-hook.d.ts +5 -0
- package/dist/install-claude-hook.js +111 -26
- package/dist/rubric.js +1 -1
- package/dist/secret-shapes.d.ts +28 -0
- package/dist/secret-shapes.js +132 -0
- package/dist/server-registry.d.ts +11 -1
- package/dist/server-registry.js +29 -4
- package/package.json +4 -3
|
@@ -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
|
-
/**
|
|
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
|
/**
|
package/dist/server-registry.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
55
|
+
"url": "https://github.com/FASTPROD/opscontext-mcp.git"
|
|
56
56
|
},
|
|
57
57
|
"homepage": "https://compr.fr",
|
|
58
58
|
"bugs": {
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"!dist/test*",
|
|
67
67
|
"!dist/**/*.map",
|
|
68
68
|
"defaults/",
|
|
69
|
+
"!defaults/__pycache__",
|
|
69
70
|
"skills/",
|
|
70
71
|
"examples/",
|
|
71
72
|
"LICENSE",
|