@compr/opscontext-mcp 2.10.0 → 2.12.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 +81 -1
- package/dist/audit.d.ts +80 -3
- package/dist/audit.js +735 -96
- package/dist/cli.js +67 -5
- package/dist/firewall.js +17 -7
- package/dist/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- package/dist/http-server.js +12 -2
- package/dist/index.js +85 -4
- package/dist/learnings.d.ts +3 -0
- package/dist/learnings.js +100 -6
- package/dist/secret-shapes.js +7 -4
- package/dist/server-registry.d.ts +3 -0
- package/dist/server-registry.js +67 -3
- package/package.json +1 -1
package/dist/secret-shapes.js
CHANGED
|
@@ -50,13 +50,16 @@ export const SECRET_SHAPES = [
|
|
|
50
50
|
{ id: "api_key_header", re: /(\bx-api-key:\s*)[^\s'"]{8,}/gi, keepPrefix: true },
|
|
51
51
|
// name = value, the long tail. Skips variables, env lookups, paths, placeholders already
|
|
52
52
|
// redacted, type names and function calls.
|
|
53
|
-
// A name that
|
|
54
|
-
//
|
|
55
|
-
//
|
|
53
|
+
// A name that ends in a separator plus PASS (SMTP_PASS, DB_PASS, db.pass) counts too: a real mail
|
|
54
|
+
// password sat in the shared index under SMTP_PASS (E2E_REVIEW_2026-09 A6-3). The separator keeps
|
|
55
|
+
// bypass and compass out. A bare `pass` counts only before an equals sign, or before a quoted value
|
|
56
|
+
// after a colon (nodemailer's auth block): as first shipped in 2.10.0 it also took prose (a README's
|
|
57
|
+
// "PII pass" list) and code (a count of passing checks in agents.ts), which the public-release scan
|
|
58
|
+
// refused.
|
|
56
59
|
{
|
|
57
60
|
id: "credential_assignment",
|
|
58
61
|
// A backtick opens a value too: "password: `...`" in Markdown, found in the real log.
|
|
59
|
-
re: /(\b(?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)|
|
|
62
|
+
re: /(\b(?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)|[\w.-]*[_.-]pass|pass(?=["'`]?\s*=|["'`]?\s*:\s*["'`]))["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
|
|
60
63
|
keepPrefix: true,
|
|
61
64
|
skip: (value, after) => after.startsWith("(") ||
|
|
62
65
|
(/^[A-Za-z_$][\w$.]*\(/.test(value) && after.startsWith(")")) || // getToken(user), not a value
|
|
@@ -42,6 +42,8 @@ export declare const SERVER_COUNT_WARN = 3;
|
|
|
42
42
|
*/
|
|
43
43
|
export declare function buildHashOf(scriptPath: string): string | null;
|
|
44
44
|
export declare function isAlive(pid: number): boolean;
|
|
45
|
+
/** The record's own process is still running. [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] */
|
|
46
|
+
export declare function recordIsLive(rec: Pick<ServerRecord, "pid" | "started">): boolean;
|
|
45
47
|
/**
|
|
46
48
|
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
47
49
|
*/
|
|
@@ -51,6 +53,7 @@ export declare function registerServer(opts: {
|
|
|
51
53
|
corpus?: string;
|
|
52
54
|
role?: "indexer" | "reader";
|
|
53
55
|
daemon?: boolean;
|
|
56
|
+
onSignal?: () => void;
|
|
54
57
|
}): {
|
|
55
58
|
record: ServerRecord;
|
|
56
59
|
stop: () => void;
|
package/dist/server-registry.js
CHANGED
|
@@ -76,6 +76,60 @@ export function isAlive(pid) {
|
|
|
76
76
|
return e?.code === "EPERM"; // exists, not ours
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* [LOCKED] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] - 2026-09-27
|
|
81
|
+
* [NEVER] treat a registry record as a live server because its pid exists.
|
|
82
|
+
* WHY: pids are reused. A dead launchd agent's record pointed at a live unrelated process (3 of 3
|
|
83
|
+
* valid runs, E2E_REVIEW_2026-09 B5-1): for as long as that process lived, no reader took over
|
|
84
|
+
* indexing (a doc change never reached the index), nobody answered on the event port, a new chat
|
|
85
|
+
* server did not take it, and `contextengine servers` listed the unrelated process as the
|
|
86
|
+
* current launchd agent holding the port. Records survive a crash or a power cut, and pids wrap.
|
|
87
|
+
* FIX: a record is alive when its pid exists AND that process started no later than the record says
|
|
88
|
+
* the server did (the server registers after its process starts; a reused pid belongs to a
|
|
89
|
+
* process that started later). Start times come from one `ps -o pid=,lstart=` for all uncached
|
|
90
|
+
* pids of a listing, cached 60 s per pid. When ps cannot answer, the pid test alone decides, as
|
|
91
|
+
* before. A record that fails is dead: removed by the lister, never elected, never the daemon.
|
|
92
|
+
*/
|
|
93
|
+
const START_CACHE_MS = 60_000;
|
|
94
|
+
const startCache = new Map();
|
|
95
|
+
function refreshStartTimes(pids) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const need = pids.filter((p) => { const c = startCache.get(p); return !c || now - c.at > START_CACHE_MS; });
|
|
98
|
+
if (need.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
let out = "";
|
|
101
|
+
try {
|
|
102
|
+
// Hardcoded argv, no shell: the only variables are numbers.
|
|
103
|
+
out = execFileSync("ps", ["-o", "pid=,lstart=", "-p", need.join(",")], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
const stdout = e.stdout;
|
|
107
|
+
out = typeof stdout === "string" ? stdout : ""; // ps exits 1 when some pid is gone; keep what it printed
|
|
108
|
+
}
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const line of out.split("\n")) {
|
|
111
|
+
const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
112
|
+
if (!m)
|
|
113
|
+
continue;
|
|
114
|
+
const t = Date.parse(m[2]);
|
|
115
|
+
seen.add(Number(m[1]));
|
|
116
|
+
startCache.set(Number(m[1]), { startedMs: Number.isNaN(t) ? null : t, at: now });
|
|
117
|
+
}
|
|
118
|
+
for (const p of need)
|
|
119
|
+
if (!seen.has(p))
|
|
120
|
+
startCache.set(p, { startedMs: null, at: now });
|
|
121
|
+
}
|
|
122
|
+
/** The record's own process is still running. [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] */
|
|
123
|
+
export function recordIsLive(rec) {
|
|
124
|
+
if (!isAlive(rec.pid))
|
|
125
|
+
return false;
|
|
126
|
+
refreshStartTimes([rec.pid]);
|
|
127
|
+
const startedMs = startCache.get(rec.pid)?.startedMs ?? null;
|
|
128
|
+
const registered = Date.parse(rec.started);
|
|
129
|
+
if (startedMs === null || Number.isNaN(registered))
|
|
130
|
+
return true; // cannot tell: the pid test decides
|
|
131
|
+
return startedMs <= registered + 5_000; // lstart has one-second resolution
|
|
132
|
+
}
|
|
79
133
|
/**
|
|
80
134
|
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
81
135
|
*/
|
|
@@ -119,7 +173,11 @@ export function registerServer(opts) {
|
|
|
119
173
|
};
|
|
120
174
|
process.on("exit", stop);
|
|
121
175
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
122
|
-
|
|
176
|
+
// The record goes first; the caller may end the process its own way. [LOCK] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]
|
|
177
|
+
process.on(sig, () => { stop(); if (opts.onSignal)
|
|
178
|
+
opts.onSignal();
|
|
179
|
+
else
|
|
180
|
+
process.exit(0); });
|
|
123
181
|
}
|
|
124
182
|
const setRole = (role) => { record.role = role; write(); };
|
|
125
183
|
const setEventPort = (port) => {
|
|
@@ -141,7 +199,7 @@ export function liveDaemonPid(exceptPid = process.pid) {
|
|
|
141
199
|
continue;
|
|
142
200
|
try {
|
|
143
201
|
const rec = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
144
|
-
if (rec.daemon && rec.pid !== exceptPid &&
|
|
202
|
+
if (rec.daemon && rec.pid !== exceptPid && recordIsLive(rec))
|
|
145
203
|
return rec.pid;
|
|
146
204
|
}
|
|
147
205
|
catch {
|
|
@@ -156,6 +214,7 @@ export function listServers() {
|
|
|
156
214
|
const report = { servers: [], removed: 0, warnings: [] };
|
|
157
215
|
if (!existsSync(dir))
|
|
158
216
|
return report;
|
|
217
|
+
const records = [];
|
|
159
218
|
for (const f of readdirSync(dir)) {
|
|
160
219
|
if (!f.endsWith(".json"))
|
|
161
220
|
continue;
|
|
@@ -172,7 +231,12 @@ export function listServers() {
|
|
|
172
231
|
report.removed++;
|
|
173
232
|
continue;
|
|
174
233
|
}
|
|
175
|
-
|
|
234
|
+
records.push({ path, rec });
|
|
235
|
+
}
|
|
236
|
+
refreshStartTimes(records.filter(({ rec }) => isAlive(rec.pid)).map(({ rec }) => rec.pid)); // one ps for the listing
|
|
237
|
+
for (const { path, rec } of records) {
|
|
238
|
+
// [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS]
|
|
239
|
+
if (!recordIsLive(rec)) {
|
|
176
240
|
try {
|
|
177
241
|
unlinkSync(path);
|
|
178
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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",
|