@compr/opscontext-mcp 2.8.4 → 2.9.1
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 +80 -0
- package/README.md +17 -17
- package/defaults/claude-code-hook.sh +11 -3
- 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/hooks.js +26 -13
- package/dist/http-server.d.ts +7 -0
- package/dist/http-server.js +38 -1
- package/dist/index.js +20 -1
- 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 +3 -3
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/hooks.js
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
// project gets its own `.contextengine/policy.json`. This rule only
|
|
44
44
|
// applies to the repo it lives in. Cross-repo enforcement is a
|
|
45
45
|
// separate concern (org-policy distribution via `extends:`).
|
|
46
|
-
import {
|
|
46
|
+
import { execFileSync } from "child_process";
|
|
47
47
|
import { existsSync, readFileSync } from "fs";
|
|
48
48
|
import { createHash } from "crypto";
|
|
49
49
|
import { join } from "path";
|
|
@@ -107,25 +107,36 @@ export function matchesAnyGlob(path, globs) {
|
|
|
107
107
|
* Errors from git (not a repo, no staged changes, etc.) are RE-THROWN, not
|
|
108
108
|
* swallowed. The caller decides whether "no staged changes" is fatal.
|
|
109
109
|
*/
|
|
110
|
+
// [LOCKED] [STAGED-PATH-IS-AN-ARGUMENT-NEVER-A-SHELL-STRING] - 2026-09-25
|
|
111
|
+
// [NEVER] build a shell command string from a staged path, a policy path, or any other value a
|
|
112
|
+
// repository controls. Pass it as one argv element to execFileSync, list names with -z,
|
|
113
|
+
// and address the file with a `:(literal)` pathspec (here) or a `:path` blob spec
|
|
114
|
+
// (contentAfterCommit below).
|
|
115
|
+
// WHY: until 2.9.0 this ran `git diff --cached -- "<name>"` through a shell, escaping only the
|
|
116
|
+
// double quote. Proven in a scratch repo on 2026-09-25: a staged file named
|
|
117
|
+
// `note$(touch PROOF).md` ran `touch` while the pre-commit scanner parsed the staged list,
|
|
118
|
+
// so a cloned repo or a pull request could run a command on the committer's machine. The
|
|
119
|
+
// same line also lost every non-ASCII name: `--name-only` prints `caf\303\251.md` C-quoted,
|
|
120
|
+
// the shell lookup found no such file, the catch skipped it, and a planted key in `café.md`
|
|
121
|
+
// was never scanned. A plain pathspec is a glob too: `a*.md` also matched `ab.md`.
|
|
122
|
+
// FIX: execFileSync("git", [...]) with the name as its own argument (no shell exists to expand
|
|
123
|
+
// it), `-z` so git prints raw names, `:(literal)` so a name matches only itself, and a
|
|
124
|
+
// 32 MB buffer so a large file is not skipped silently. tests/hooks.test.ts and
|
|
125
|
+
// tests/rule-parity.test.ts stage those exact names.
|
|
110
126
|
export function getStagedFiles(repoRoot) {
|
|
111
|
-
const nameOutput =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}).trim();
|
|
115
|
-
if (!nameOutput)
|
|
127
|
+
const nameOutput = execFileSync("git", ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], { cwd: repoRoot, encoding: "utf-8" });
|
|
128
|
+
const fileNames = nameOutput.split("\0").filter((f) => f.length > 0);
|
|
129
|
+
if (fileNames.length === 0)
|
|
116
130
|
return [];
|
|
117
|
-
const fileNames = nameOutput.split("\n");
|
|
118
131
|
const result = [];
|
|
119
132
|
for (const file of fileNames) {
|
|
120
133
|
let diff;
|
|
121
134
|
try {
|
|
122
|
-
diff =
|
|
123
|
-
cwd: repoRoot,
|
|
124
|
-
encoding: "utf-8",
|
|
125
|
-
});
|
|
135
|
+
diff = execFileSync("git", ["diff", "--cached", "--unified=0", "--", `:(literal)${file}`], { cwd: repoRoot, encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 });
|
|
126
136
|
}
|
|
127
137
|
catch {
|
|
128
|
-
//
|
|
138
|
+
// git could not diff this path (index and worktree disagree in a way git refuses to
|
|
139
|
+
// print, or the diff exceeds the buffer): nothing to parse, skip it.
|
|
129
140
|
continue;
|
|
130
141
|
}
|
|
131
142
|
const addedLines = [];
|
|
@@ -586,7 +597,9 @@ export function formatDocCoverageViolationsJson(violations) {
|
|
|
586
597
|
function contentAfterCommit(repoRoot, rel, stagedSet, useWorktree) {
|
|
587
598
|
if (!useWorktree && stagedSet.has(rel)) {
|
|
588
599
|
try {
|
|
589
|
-
|
|
600
|
+
// [LOCK] [STAGED-PATH-IS-AN-ARGUMENT-NEVER-A-SHELL-STRING]: `rel` comes from policy.json,
|
|
601
|
+
// which a cloned repo controls; it is an argv element, never part of a shell string.
|
|
602
|
+
return execFileSync("git", ["show", `:${rel}`], {
|
|
590
603
|
cwd: repoRoot,
|
|
591
604
|
encoding: "utf-8",
|
|
592
605
|
maxBuffer: 32 * 1024 * 1024,
|
package/dist/http-server.d.ts
CHANGED
|
@@ -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;
|
package/dist/http-server.js
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
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
|
-
/**
|
|
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.1",
|
|
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": {
|