@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/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 { readFileSync, existsSync, watch, statSync } from "fs";
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. Compliance basis: SOC2 CC7.2, ISO 27001 A.12.4.1. 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.", {
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