@compr/opscontext-mcp 2.1.0 → 2.1.3
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 +150 -1
- package/README.md +73 -8
- package/defaults/claude-code-hook.sh +100 -0
- package/dist/activation.js +29 -7
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +133 -14
- package/dist/cli.js +354 -16
- package/dist/community-export.d.ts +84 -0
- package/dist/community-export.js +400 -0
- package/dist/community-sync.d.ts +100 -0
- package/dist/community-sync.js +506 -0
- package/dist/hooks.d.ts +64 -0
- package/dist/hooks.js +240 -0
- package/dist/index.js +35 -2
- package/dist/install-autostart.d.ts +4 -0
- package/dist/install-autostart.js +356 -0
- package/dist/install-claude-hook.d.ts +3 -0
- package/dist/install-claude-hook.js +180 -0
- package/dist/policy.d.ts +42 -0
- package/dist/policy.js +40 -0
- package/dist/tools-manifest.d.ts +37 -0
- package/dist/tools-manifest.js +64 -0
- package/package.json +4 -3
- package/skills/opscontext/SKILL.md +1 -1
package/dist/hooks.js
CHANGED
|
@@ -16,6 +16,33 @@
|
|
|
16
16
|
// Inputs are fed by helpers that read the actual git working state.
|
|
17
17
|
// Each runner returns a list of violations + a summary; the CLI maps that
|
|
18
18
|
// onto exit codes + audit events + human/machine output.
|
|
19
|
+
//
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// ACCEPTED LIMITS — out-of-scope by design (do NOT classify these as bugs)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// 1. `git commit --no-verify` — documented git escape hatch. Cannot be
|
|
24
|
+
// caught at the hook layer (git skips ALL hooks under that flag).
|
|
25
|
+
// Mitigation lives upstream of git (CODEOWNERS / branch protection /
|
|
26
|
+
// server-side push rules), not here.
|
|
27
|
+
// 2. Workflow ID format (`wf_x`) is NOT verified against actual
|
|
28
|
+
// audit-log entries — adding that would require a server-side query
|
|
29
|
+
// against the OpsContext audit-log service. Deferred to a later
|
|
30
|
+
// iteration. We only enforce the text-shape of the citation, not that
|
|
31
|
+
// it corresponds to a real workflow.
|
|
32
|
+
// 3. Direct SSH edits, cron edits, Cloudflare UI edits — out of scope
|
|
33
|
+
// by design. If the change never enters git, no commit hook can
|
|
34
|
+
// observe it. Defense-in-depth is via sidecar collectors (PM2,
|
|
35
|
+
// systemd, cron, Cloudflare audit log) feeding the central audit log.
|
|
36
|
+
// 4. Glob case-sensitivity on case-insensitive filesystems (APFS / NTFS)
|
|
37
|
+
// — git itself does NOT normalize this. A rule scoped to
|
|
38
|
+
// `server/Deploy.sh` will not match a path `server/deploy.sh` on
|
|
39
|
+
// Linux but WILL match on macOS-default APFS. Mitigation: author
|
|
40
|
+
// policy.json with case-sensitive paths that match git's stored
|
|
41
|
+
// paths exactly.
|
|
42
|
+
// 5. Sibling-project deploy scripts (KONIVE / PLANK / etc.) — each
|
|
43
|
+
// project gets its own `.contextengine/policy.json`. This rule only
|
|
44
|
+
// applies to the repo it lives in. Cross-repo enforcement is a
|
|
45
|
+
// separate concern (org-policy distribution via `extends:`).
|
|
19
46
|
import { execSync } from "child_process";
|
|
20
47
|
import { existsSync, readFileSync } from "fs";
|
|
21
48
|
import { createHash } from "crypto";
|
|
@@ -279,6 +306,219 @@ export function formatDocCoverageViolations(violations) {
|
|
|
279
306
|
return lines.join("\n");
|
|
280
307
|
}
|
|
281
308
|
// ---------------------------------------------------------------------------
|
|
309
|
+
// Commit-message-required checker (Agent B — Option B consumer)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
//
|
|
312
|
+
// Consumes the `commit_message_required` rule type added by Agent A
|
|
313
|
+
// (CommitMessageRequiredSchema in src/policy.ts, sample rule
|
|
314
|
+
// `multi-agent-for-shared-infra` in .contextengine/policy.json).
|
|
315
|
+
//
|
|
316
|
+
// Trigger: any staged file path matches the rule's `paths` globs.
|
|
317
|
+
// Pass: the commit message matches the rule's `pattern` (ERE regex).
|
|
318
|
+
// Bypass: the commit body contains `--skip-multi-agent-reason: <reason>`
|
|
319
|
+
// — the bypass IS recorded as a separate kind (`bypass`) so the
|
|
320
|
+
// CLI can emit a `policy.skipped` audit event with the reason.
|
|
321
|
+
//
|
|
322
|
+
// We intentionally do NOT swallow the bypass into "no violation":
|
|
323
|
+
// surfacing it as a `bypass` kind lets the CLI keep the audit-log append
|
|
324
|
+
// in the same place as `hook.block` events, preserving symmetry.
|
|
325
|
+
/** Canonical bypass marker. Lives at the top of the checker so editors
|
|
326
|
+
* can grep for it when reviewing the audit story. */
|
|
327
|
+
export const COMMIT_BYPASS_PREFIX = "--skip-multi-agent-reason:";
|
|
328
|
+
/** Minimum reason length when bypassing. Hardened 2026-06-26 (verifier
|
|
329
|
+
* Bypass #11): the previous 5-char floor passed `12345` as a "valid"
|
|
330
|
+
* reason — no semantic content, just a placeholder to defeat the gate.
|
|
331
|
+
* Now 20 chars + at least one whitespace character (real reasons are
|
|
332
|
+
* prose with words, not concatenated tokens). Matches
|
|
333
|
+
* BypassTokenSchema.requires_reason_min_length default of 20. */
|
|
334
|
+
const MIN_BYPASS_REASON_LENGTH = 20;
|
|
335
|
+
/** Extract a bypass reason from the commit message. Returns the
|
|
336
|
+
* reason text (trimmed) or null when absent.
|
|
337
|
+
*
|
|
338
|
+
* Hardened 2026-06-26 (verifier Bypass #5 + #11):
|
|
339
|
+
* - The bypass-marker MUST appear at the start of its own line
|
|
340
|
+
* (only leading whitespace allowed). Mid-line or commented-out
|
|
341
|
+
* markers like `# Note: do NOT use --skip-multi-agent-reason: ever`
|
|
342
|
+
* are REJECTED — the `#` (or any non-whitespace char) before the
|
|
343
|
+
* prefix on the same line disqualifies the line.
|
|
344
|
+
* - Reason must be ≥ MIN_BYPASS_REASON_LENGTH chars (20).
|
|
345
|
+
* - Reason must contain at least one whitespace character — real
|
|
346
|
+
* reasons are prose ("emergency rollback at 03:00 UTC"), not
|
|
347
|
+
* alphanumeric placeholders ("12345" / "abc123def456…"). A token
|
|
348
|
+
* without spaces is almost certainly a defeat attempt.
|
|
349
|
+
*/
|
|
350
|
+
export function extractBypassReason(commitMessage) {
|
|
351
|
+
for (const rawLine of commitMessage.split(/\r?\n/)) {
|
|
352
|
+
// Allow ONLY leading whitespace before the prefix (no `#`, no quote,
|
|
353
|
+
// no other prose). Verifier Bypass #5 attack vector closed here.
|
|
354
|
+
const leadingMatch = rawLine.match(/^(\s*)/);
|
|
355
|
+
const leading = leadingMatch ? leadingMatch[1] : "";
|
|
356
|
+
const afterLeading = rawLine.slice(leading.length);
|
|
357
|
+
if (!afterLeading.startsWith(COMMIT_BYPASS_PREFIX))
|
|
358
|
+
continue;
|
|
359
|
+
const reason = afterLeading.slice(COMMIT_BYPASS_PREFIX.length).trim();
|
|
360
|
+
if (reason.length < MIN_BYPASS_REASON_LENGTH)
|
|
361
|
+
continue;
|
|
362
|
+
// Real reasons contain at least one space — reject "12345",
|
|
363
|
+
// "abcdef1234567890ABCDE", and other word-less placeholders.
|
|
364
|
+
if (!/\s/.test(reason))
|
|
365
|
+
continue;
|
|
366
|
+
return reason;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Strip any top-level alternation branch from a policy regex pattern
|
|
372
|
+
* that contains the literal COMMIT_BYPASS_PREFIX. Verifier Bypass #3:
|
|
373
|
+
* the canonical policy pattern
|
|
374
|
+
*
|
|
375
|
+
* Multi-agent: wf_[a-z0-9-]+|--skip-multi-agent-reason: .+
|
|
376
|
+
*
|
|
377
|
+
* had two branches in one pattern; the second branch matched the literal
|
|
378
|
+
* bypass marker anywhere in the message (including mid-line, commented
|
|
379
|
+
* out, etc.), short-circuiting the strict line-anchored validation in
|
|
380
|
+
* extractBypassReason. Fix: the matcher in runCommitMessageRequired
|
|
381
|
+
* ignores the bypass-marker branch entirely — bypass MUST go through
|
|
382
|
+
* extractBypassReason's hardened validation.
|
|
383
|
+
*
|
|
384
|
+
* Returns the cleaned pattern. If every branch is bypass-related (rare
|
|
385
|
+
* footgun), returns null — caller treats as "no positive branch to
|
|
386
|
+
* satisfy", which is the safe default.
|
|
387
|
+
*/
|
|
388
|
+
export function stripBypassBranchFromPattern(pattern) {
|
|
389
|
+
// Top-level alternation split. This is a SIMPLE split — patterns with
|
|
390
|
+
// nested grouping that uses `|` inside `(...)` aren't decomposed, but
|
|
391
|
+
// the canonical policy patterns don't use that shape. If a future
|
|
392
|
+
// policy needs nested alternation, the safer move is to author the
|
|
393
|
+
// bypass branch out of the policy entirely (it belongs to
|
|
394
|
+
// extractBypassReason, not to the pattern).
|
|
395
|
+
const branches = pattern.split("|");
|
|
396
|
+
const cleaned = branches.filter((b) => !b.includes(COMMIT_BYPASS_PREFIX));
|
|
397
|
+
if (cleaned.length === 0)
|
|
398
|
+
return null;
|
|
399
|
+
return cleaned.join("|");
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Apply policy.commit_message_required rules to a staged-file list +
|
|
403
|
+
* commit message. Returns one entry per fired rule (either a
|
|
404
|
+
* missing-pattern violation OR a bypass acknowledgement).
|
|
405
|
+
*
|
|
406
|
+
* Empty list = nothing fired (either no rule's `paths` matched, or every
|
|
407
|
+
* fired rule was satisfied by the message).
|
|
408
|
+
*
|
|
409
|
+
* IMPORTANT: This does NOT itself decide exit codes. The caller (CLI)
|
|
410
|
+
* decides: missing-pattern + severity=block → exit 1 + hook.block event;
|
|
411
|
+
* bypass → exit 0 + policy.skipped event with the reason.
|
|
412
|
+
*/
|
|
413
|
+
export function runCommitMessageRequired(policy, files, commitMessage) {
|
|
414
|
+
const results = [];
|
|
415
|
+
const bypassReason = extractBypassReason(commitMessage);
|
|
416
|
+
for (const rule of policy.commit_message_required) {
|
|
417
|
+
const matchedFiles = files
|
|
418
|
+
.map((f) => f.path)
|
|
419
|
+
.filter((p) => matchesAnyGlob(p, rule.paths));
|
|
420
|
+
if (matchedFiles.length === 0)
|
|
421
|
+
continue; // rule did not fire
|
|
422
|
+
// Bypass takes precedence — record it, do not block.
|
|
423
|
+
if (bypassReason !== null) {
|
|
424
|
+
results.push({
|
|
425
|
+
kind: "bypass",
|
|
426
|
+
severity: rule.severity,
|
|
427
|
+
ruleId: rule.id,
|
|
428
|
+
matchedFiles,
|
|
429
|
+
pattern: rule.pattern,
|
|
430
|
+
description: rule.description,
|
|
431
|
+
bypassReason,
|
|
432
|
+
});
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
// No bypass — check the pattern. Strip any bypass-marker alternation
|
|
436
|
+
// branch first (verifier Bypass #3): the bypass path is owned by
|
|
437
|
+
// extractBypassReason ONLY. Pattern-matching the bypass marker text
|
|
438
|
+
// is forbidden because the policy regex has no line-anchoring and
|
|
439
|
+
// therefore cannot distinguish a real bypass from a quoted/commented
|
|
440
|
+
// mention of the marker.
|
|
441
|
+
const cleanedPattern = stripBypassBranchFromPattern(rule.pattern);
|
|
442
|
+
if (cleanedPattern === null) {
|
|
443
|
+
// Pattern was 100% bypass-branches. Treat as missing-pattern —
|
|
444
|
+
// the rule has no positive enforcement branch left after
|
|
445
|
+
// sanitization.
|
|
446
|
+
results.push({
|
|
447
|
+
kind: "missing-pattern",
|
|
448
|
+
severity: rule.severity,
|
|
449
|
+
ruleId: rule.id,
|
|
450
|
+
matchedFiles,
|
|
451
|
+
pattern: rule.pattern,
|
|
452
|
+
description: rule.description,
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
let re;
|
|
457
|
+
try {
|
|
458
|
+
re = new RegExp(cleanedPattern);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// Malformed pattern in policy.json — surface as a missing-pattern
|
|
462
|
+
// violation rather than crashing the hook. The CLI will print the
|
|
463
|
+
// rule.description, which should explain the contract.
|
|
464
|
+
results.push({
|
|
465
|
+
kind: "missing-pattern",
|
|
466
|
+
severity: rule.severity,
|
|
467
|
+
ruleId: rule.id,
|
|
468
|
+
matchedFiles,
|
|
469
|
+
pattern: rule.pattern,
|
|
470
|
+
description: rule.description,
|
|
471
|
+
});
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (!re.test(commitMessage)) {
|
|
475
|
+
results.push({
|
|
476
|
+
kind: "missing-pattern",
|
|
477
|
+
severity: rule.severity,
|
|
478
|
+
ruleId: rule.id,
|
|
479
|
+
matchedFiles,
|
|
480
|
+
pattern: rule.pattern,
|
|
481
|
+
description: rule.description,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return results;
|
|
486
|
+
}
|
|
487
|
+
export function formatCommitMessageViolations(violations) {
|
|
488
|
+
if (violations.length === 0)
|
|
489
|
+
return "✅ All commit-message-required rules satisfied.";
|
|
490
|
+
const lines = [];
|
|
491
|
+
const blocking = violations.filter((v) => v.kind === "missing-pattern" && v.severity === "block").length;
|
|
492
|
+
const bypasses = violations.filter((v) => v.kind === "bypass").length;
|
|
493
|
+
lines.push(`📝 COMMIT MESSAGE POLICY: ${violations.length} rule(s) fired — ${blocking} blocking, ${bypasses} bypassed.`);
|
|
494
|
+
for (const v of violations) {
|
|
495
|
+
if (v.kind === "bypass") {
|
|
496
|
+
lines.push(` [bypass] ${v.ruleId} on ${v.matchedFiles.join(", ")} — reason: "${v.bypassReason}" (logged to audit)`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
lines.push(` [${v.severity}] ${v.ruleId} on ${v.matchedFiles.join(", ")} — commit body must match /${v.pattern}/`);
|
|
500
|
+
if (v.description)
|
|
501
|
+
lines.push(` ${v.description}`);
|
|
502
|
+
}
|
|
503
|
+
return lines.join("\n");
|
|
504
|
+
}
|
|
505
|
+
export function formatCommitMessageViolationsJson(violations) {
|
|
506
|
+
return JSON.stringify({
|
|
507
|
+
check: "commit-message-required",
|
|
508
|
+
violations_total: violations.length,
|
|
509
|
+
blocking: violations.filter((v) => v.kind === "missing-pattern" && v.severity === "block").length,
|
|
510
|
+
bypasses: violations.filter((v) => v.kind === "bypass").length,
|
|
511
|
+
violations: violations.map((v) => ({
|
|
512
|
+
kind: v.kind,
|
|
513
|
+
severity: v.severity,
|
|
514
|
+
rule_id: v.ruleId,
|
|
515
|
+
matched_files: v.matchedFiles,
|
|
516
|
+
pattern: v.pattern,
|
|
517
|
+
bypass_reason: v.bypassReason,
|
|
518
|
+
})),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
282
522
|
// Machine-readable output (for CI pipelines)
|
|
283
523
|
// ---------------------------------------------------------------------------
|
|
284
524
|
export function formatSecretViolationsJson(violations) {
|
package/dist/index.js
CHANGED
|
@@ -14,11 +14,14 @@ import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
|
|
|
14
14
|
import { startEventIngestServer } from "./http-server.js";
|
|
15
15
|
import { detect } from "./detector.js";
|
|
16
16
|
import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
|
|
17
|
-
import {
|
|
17
|
+
import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./community-sync.js";
|
|
18
|
+
import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
|
|
18
19
|
import { basename, join, dirname } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
19
21
|
import { execSync } from "child_process";
|
|
20
22
|
import { scanCodeDir } from "./code-chunker.js";
|
|
21
23
|
import { fileURLToPath } from "url";
|
|
24
|
+
import { TOOL_COUNT, FREE_TOOL_COUNT, PREMIUM_TOOL_NAMES } from "./tools-manifest.js";
|
|
22
25
|
// Read version from package.json at startup
|
|
23
26
|
let PKG_VERSION = "1.21.3";
|
|
24
27
|
try {
|
|
@@ -112,6 +115,19 @@ async function reindex() {
|
|
|
112
115
|
chunks.push(...learningChunks);
|
|
113
116
|
console.error(`[ContextEngine] 💡 Injected ${learningChunks.length} learning chunks into search index (scoped to ${activeProjectNames.length} projects)`);
|
|
114
117
|
}
|
|
118
|
+
// Inject community rules from the cached store (best-effort — no network
|
|
119
|
+
// touched here; the daily `sync-community-rules` CLI keeps the cache fresh).
|
|
120
|
+
// Deduped against the local learnings so identical content never double-emits.
|
|
121
|
+
const communityChunks = communityRulesToChunks();
|
|
122
|
+
if (communityChunks.length > 0) {
|
|
123
|
+
const before = chunks.length;
|
|
124
|
+
chunks = mergeWithDedup(chunks, communityChunks);
|
|
125
|
+
const added = chunks.length - before;
|
|
126
|
+
const skipped = communityChunks.length - added;
|
|
127
|
+
const store = loadCommunityStore();
|
|
128
|
+
console.error(`[ContextEngine] 🌐 Injected ${added} community rule chunks ` +
|
|
129
|
+
`(${skipped} dedup'd vs local; ${store.rules.length} total in cache)`);
|
|
130
|
+
}
|
|
115
131
|
// Collect from plugin adapters
|
|
116
132
|
if (config.adapters && config.adapters.length > 0) {
|
|
117
133
|
const adapterChunks = await collectFromAdapters(config.adapters);
|
|
@@ -551,7 +567,7 @@ server.tool("delete_session", "Delete a saved session by name. Returns success/n
|
|
|
551
567
|
// ---------------------------------------------------------------------------
|
|
552
568
|
// Tool: audit_verify (Compliance — tamper-evident audit log)
|
|
553
569
|
// ---------------------------------------------------------------------------
|
|
554
|
-
server.tool("audit_verify", "Verify the integrity of the local audit log chain. Returns OK + record count, or BROKEN + break index when a record has been edited or the chain otherwise diverges.
|
|
570
|
+
server.tool("audit_verify", "Verify the integrity of the local audit log chain. Returns OK + record count, or BROKEN + break index when a record has been edited or the chain otherwise diverges. Produces evidence aligned with SOC 2 CC7.2 (change monitoring) and ISO 27001 A.12.4.1 (event logging) — evidence artifacts, not a certification (OpsContext is not itself SOC 2– or ISO 27001–certified; see docs/compliance/). The audit log lives at ~/.contextengine/audit.log and records every state-changing operation (learning save/delete/import, session save/delete, activation activate/deactivate) as a hash-chained JSONL line.", {
|
|
555
571
|
since: z.string().optional().describe("ISO date — restrict integrity report counters to records on/after this timestamp (chain still verified end-to-end)"),
|
|
556
572
|
until: z.string().optional().describe("ISO date — restrict counters to records on/before this timestamp"),
|
|
557
573
|
}, async ({ since, until }) => {
|
|
@@ -1090,6 +1106,23 @@ async function main() {
|
|
|
1090
1106
|
const transport = new StdioServerTransport();
|
|
1091
1107
|
await server.connect(transport);
|
|
1092
1108
|
console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
|
|
1109
|
+
// 3b. Write server-meta.json so the VS Code extension can read tool count
|
|
1110
|
+
// without needing an active MCP session. Single source of truth =
|
|
1111
|
+
// src/tools-manifest.ts (asserted by tests/tools-manifest.test.ts).
|
|
1112
|
+
try {
|
|
1113
|
+
const metaDir = join(homedir(), ".contextengine");
|
|
1114
|
+
mkdirSync(metaDir, { recursive: true });
|
|
1115
|
+
writeFileSync(join(metaDir, "server-meta.json"), JSON.stringify({
|
|
1116
|
+
toolCount: TOOL_COUNT,
|
|
1117
|
+
freeCount: FREE_TOOL_COUNT,
|
|
1118
|
+
premiumCount: PREMIUM_TOOL_NAMES.length,
|
|
1119
|
+
version: PKG_VERSION,
|
|
1120
|
+
generatedAt: new Date().toISOString(),
|
|
1121
|
+
}, null, 2));
|
|
1122
|
+
}
|
|
1123
|
+
catch (err) {
|
|
1124
|
+
console.error("[ContextEngine] ⚠ Failed to write server-meta.json:", err);
|
|
1125
|
+
}
|
|
1093
1126
|
// 4. Load embeddings — try cache first, then model (non-blocking)
|
|
1094
1127
|
const cached = loadCache(chunks);
|
|
1095
1128
|
if (cached) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function cliInstallAutostart(args: string[]): Promise<void>;
|
|
2
|
+
export declare function cliUninstallAutostart(args: string[]): Promise<void>;
|
|
3
|
+
export declare function cliAutostartStatus(args: string[]): Promise<void>;
|
|
4
|
+
//# sourceMappingURL=install-autostart.d.ts.map
|