@compr/opscontext-mcp 2.1.1 → 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 +117 -2
- package/README.md +73 -8
- package/dist/activation.js +29 -7
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +133 -14
- package/dist/cli.js +319 -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.js +66 -10
- 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) {
|
|
@@ -18,6 +18,22 @@ import { existsSync, writeFileSync, mkdirSync } from "fs";
|
|
|
18
18
|
import { join, dirname } from "path";
|
|
19
19
|
import { homedir, platform } from "os";
|
|
20
20
|
import { execSync } from "child_process";
|
|
21
|
+
import { createRequire } from "module";
|
|
22
|
+
import { fileURLToPath } from "url";
|
|
23
|
+
// 🔒 LOCKED [M2-ESM-FILENAME-FIX] — 2026-06-24
|
|
24
|
+
// ⛔ NEVER reference bare `__filename` in this file — the package is
|
|
25
|
+
// `"type": "module"` so __filename is `undefined` at runtime and
|
|
26
|
+
// `dirname(__filename || "")` was returning dirname("") = "." which
|
|
27
|
+
// silently broke the dev-tree fallback. Audit FRESH_USER_AUDIT_
|
|
28
|
+
// 2026-06-23.md finding M2.
|
|
29
|
+
// FIX: Resolve module path via fileURLToPath(import.meta.url). For
|
|
30
|
+
// cross-package resolution (e.g. when running via npx and the
|
|
31
|
+
// @compr/opscontext-mcp tarball is in npx's transient cache), also
|
|
32
|
+
// try createRequire(import.meta.url).resolve("@compr/opscontext-mcp/
|
|
33
|
+
// dist/index.js") which works inside npx.
|
|
34
|
+
const __filename_esm = fileURLToPath(import.meta.url);
|
|
35
|
+
const __dirname_esm = dirname(__filename_esm);
|
|
36
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
21
37
|
const LABEL = "com.opscontext.mcp";
|
|
22
38
|
const PLIST_FILE = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
23
39
|
const LOG_DIR = join(homedir(), ".contextengine", "logs");
|
|
@@ -34,7 +50,7 @@ function detectNodePath() {
|
|
|
34
50
|
* (2) ./dist/index.js next to this module (dev tree). Falls through with
|
|
35
51
|
* null if neither is found. */
|
|
36
52
|
function detectOpscontextEntry() {
|
|
37
|
-
// Try global install via `npm root -g`
|
|
53
|
+
// (1) Try global install via `npm root -g`
|
|
38
54
|
try {
|
|
39
55
|
const globalRoot = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
40
56
|
const candidate = join(globalRoot, "@compr", "opscontext-mcp", "dist", "index.js");
|
|
@@ -44,19 +60,31 @@ function detectOpscontextEntry() {
|
|
|
44
60
|
catch {
|
|
45
61
|
/* no npm root available; fall through */
|
|
46
62
|
}
|
|
47
|
-
// Try dev tree relative to this module's location (dist/install-autostart.js)
|
|
48
|
-
// →
|
|
63
|
+
// (2) Try dev tree relative to this module's location (dist/install-autostart.js)
|
|
64
|
+
// → __dirname_esm IS dist/, so dist/index.js is a sibling. ESM-safe (uses
|
|
65
|
+
// fileURLToPath(import.meta.url), not the broken `__filename` reference
|
|
66
|
+
// that the M2 audit caught).
|
|
49
67
|
try {
|
|
50
|
-
|
|
51
|
-
// via require.resolve fallback. We're loaded from dist/, so look at sibling.
|
|
52
|
-
const here = dirname(__filename || "");
|
|
53
|
-
const candidate = join(here, "index.js");
|
|
68
|
+
const candidate = join(__dirname_esm, "index.js");
|
|
54
69
|
if (existsSync(candidate))
|
|
55
70
|
return { kind: "devtree", path: candidate };
|
|
56
71
|
}
|
|
57
72
|
catch {
|
|
58
73
|
/* ignore */
|
|
59
74
|
}
|
|
75
|
+
// (3) NEW: Try resolve via createRequire (works inside npx's transient
|
|
76
|
+
// install — when the user runs `npx -y @compr/opscontext-mcp
|
|
77
|
+
// install-autostart` the package is in npx's cache, not npm's global root,
|
|
78
|
+
// so step (1) misses. createRequire walks Node's resolution algorithm and
|
|
79
|
+
// finds the cache copy. Audit M2 fix.
|
|
80
|
+
try {
|
|
81
|
+
const resolved = requireFromHere.resolve("@compr/opscontext-mcp/dist/index.js");
|
|
82
|
+
if (existsSync(resolved))
|
|
83
|
+
return { kind: "npx", path: resolved };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* not resolvable — caller will print the install-globally hint */
|
|
87
|
+
}
|
|
60
88
|
return null;
|
|
61
89
|
}
|
|
62
90
|
function buildPlist(nodePath, entryPath, nodeBinDir) {
|
|
@@ -163,12 +191,40 @@ To view server logs: tail -f ~/.contextengine/logs/mcp-stderr.log
|
|
|
163
191
|
console.error(` Pass --force to overwrite, or run: opscontext autostart-status`);
|
|
164
192
|
process.exit(1);
|
|
165
193
|
}
|
|
194
|
+
// Allow operator to pin the entry path explicitly — escape hatch when
|
|
195
|
+
// detection fails (e.g. monorepo / private registry / unconventional layout).
|
|
196
|
+
// Audit M2 follow-up: was originally a CLI flag suggestion.
|
|
197
|
+
const entryFlagIdx = args.findIndex((a) => a === "--entry" || a.startsWith("--entry="));
|
|
198
|
+
let entry = null;
|
|
199
|
+
if (entryFlagIdx >= 0) {
|
|
200
|
+
const raw = args[entryFlagIdx].includes("=")
|
|
201
|
+
? args[entryFlagIdx].split("=")[1]
|
|
202
|
+
: args[entryFlagIdx + 1];
|
|
203
|
+
if (!raw) {
|
|
204
|
+
console.error(`❌ --entry requires a path. Usage: --entry=/path/to/dist/index.js`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
if (!existsSync(raw)) {
|
|
208
|
+
console.error(`❌ --entry path does not exist: ${raw}`);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
entry = { kind: "manual", path: raw };
|
|
212
|
+
}
|
|
166
213
|
const nodePath = detectNodePath();
|
|
167
|
-
|
|
214
|
+
if (!entry)
|
|
215
|
+
entry = detectOpscontextEntry();
|
|
168
216
|
if (!entry) {
|
|
169
|
-
console.error(`❌ Could not locate opscontext entrypoint
|
|
170
|
-
console.error(`
|
|
217
|
+
console.error(`❌ Could not locate opscontext entrypoint. Tried 3 paths:`);
|
|
218
|
+
console.error(` (1) npm global root → @compr/opscontext-mcp/dist/index.js`);
|
|
219
|
+
console.error(` (2) dev tree sibling (this script's dist/ dir)`);
|
|
220
|
+
console.error(` (3) Node resolution of "@compr/opscontext-mcp/dist/index.js" via createRequire (npx cache)`);
|
|
221
|
+
console.error(``);
|
|
222
|
+
console.error(` If you installed with npx, install globally first:`);
|
|
223
|
+
console.error(` npm install -g @compr/opscontext-mcp`);
|
|
224
|
+
console.error(``);
|
|
171
225
|
console.error(` Or run from a clone: cd .../ContextEngine && npm run build`);
|
|
226
|
+
console.error(``);
|
|
227
|
+
console.error(` Or pin a path explicitly: opscontext install-autostart --entry=/full/path/to/dist/index.js`);
|
|
172
228
|
process.exit(1);
|
|
173
229
|
}
|
|
174
230
|
// Ensure log dir
|
package/dist/policy.d.ts
CHANGED
|
@@ -42,6 +42,38 @@ export declare const DeployVerifyHostSchema: z.ZodObject<{
|
|
|
42
42
|
description: z.ZodOptional<z.ZodString>;
|
|
43
43
|
}, z.core.$strip>;
|
|
44
44
|
export type DeployVerifyHost = z.infer<typeof DeployVerifyHostSchema>;
|
|
45
|
+
/**
|
|
46
|
+
* A staged-path → required-commit-message-pattern rule. Fires when a
|
|
47
|
+
* commit touches any path matching `paths` AND the commit message does
|
|
48
|
+
* NOT match `pattern`. The canonical case (`multi-agent-for-shared-infra`)
|
|
49
|
+
* encodes the Session 15 / Sprint 16 lesson: shared production
|
|
50
|
+
* infrastructure changes (deploy scripts, ecosystem.config, nginx confs)
|
|
51
|
+
* must cite a multi-agent diagnostic workflow ID (`Multi-agent: wf_…`)
|
|
52
|
+
* OR carry an explicit bypass reason (`--skip-multi-agent-reason: …`)
|
|
53
|
+
* that gets recorded in the audit log.
|
|
54
|
+
*
|
|
55
|
+
* Rationale: the Sprint 16 multi-agent diagnostic (workflow `wdcraou93`)
|
|
56
|
+
* caught 5 design errors + 2 structural blockers in an Option B blue/green
|
|
57
|
+
* rollout that would otherwise have shipped and crashed sibling apps on
|
|
58
|
+
* the multi-tenant VPS. The rule turns that one-time discipline into a
|
|
59
|
+
* machine-enforced gate.
|
|
60
|
+
*
|
|
61
|
+
* NOTE: processor implementation (parsing staged paths + scanning the
|
|
62
|
+
* commit message buffer in the prepare-commit-msg or commit-msg hook)
|
|
63
|
+
* is intentionally left to a follow-up — this file only declares the
|
|
64
|
+
* shape so policy.json validation accepts the new rule today.
|
|
65
|
+
*/
|
|
66
|
+
export declare const CommitMessageRequiredSchema: z.ZodObject<{
|
|
67
|
+
id: z.ZodString;
|
|
68
|
+
paths: z.ZodArray<z.ZodString>;
|
|
69
|
+
pattern: z.ZodString;
|
|
70
|
+
severity: z.ZodDefault<z.ZodEnum<{
|
|
71
|
+
warn: "warn";
|
|
72
|
+
block: "block";
|
|
73
|
+
}>>;
|
|
74
|
+
description: z.ZodOptional<z.ZodString>;
|
|
75
|
+
}, z.core.$strip>;
|
|
76
|
+
export type CommitMessageRequired = z.infer<typeof CommitMessageRequiredSchema>;
|
|
45
77
|
/**
|
|
46
78
|
* A documented escape hatch for the hook. Beats undocumented `touch` /
|
|
47
79
|
* `--no-verify` workarounds. Bypass token requires a reason and lives in
|
|
@@ -85,6 +117,16 @@ export declare const PolicySchema: z.ZodObject<{
|
|
|
85
117
|
within_seconds: z.ZodDefault<z.ZodNumber>;
|
|
86
118
|
description: z.ZodOptional<z.ZodString>;
|
|
87
119
|
}, z.core.$strip>>>;
|
|
120
|
+
commit_message_required: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
121
|
+
id: z.ZodString;
|
|
122
|
+
paths: z.ZodArray<z.ZodString>;
|
|
123
|
+
pattern: z.ZodString;
|
|
124
|
+
severity: z.ZodDefault<z.ZodEnum<{
|
|
125
|
+
warn: "warn";
|
|
126
|
+
block: "block";
|
|
127
|
+
}>>;
|
|
128
|
+
description: z.ZodOptional<z.ZodString>;
|
|
129
|
+
}, z.core.$strip>>>;
|
|
88
130
|
bypass_tokens: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
89
131
|
id: z.ZodString;
|
|
90
132
|
ttl_seconds: z.ZodDefault<z.ZodNumber>;
|
package/dist/policy.js
CHANGED
|
@@ -58,6 +58,40 @@ export const DeployVerifyHostSchema = z.object({
|
|
|
58
58
|
within_seconds: z.number().int().positive().default(60),
|
|
59
59
|
description: z.string().optional(),
|
|
60
60
|
});
|
|
61
|
+
/**
|
|
62
|
+
* A staged-path → required-commit-message-pattern rule. Fires when a
|
|
63
|
+
* commit touches any path matching `paths` AND the commit message does
|
|
64
|
+
* NOT match `pattern`. The canonical case (`multi-agent-for-shared-infra`)
|
|
65
|
+
* encodes the Session 15 / Sprint 16 lesson: shared production
|
|
66
|
+
* infrastructure changes (deploy scripts, ecosystem.config, nginx confs)
|
|
67
|
+
* must cite a multi-agent diagnostic workflow ID (`Multi-agent: wf_…`)
|
|
68
|
+
* OR carry an explicit bypass reason (`--skip-multi-agent-reason: …`)
|
|
69
|
+
* that gets recorded in the audit log.
|
|
70
|
+
*
|
|
71
|
+
* Rationale: the Sprint 16 multi-agent diagnostic (workflow `wdcraou93`)
|
|
72
|
+
* caught 5 design errors + 2 structural blockers in an Option B blue/green
|
|
73
|
+
* rollout that would otherwise have shipped and crashed sibling apps on
|
|
74
|
+
* the multi-tenant VPS. The rule turns that one-time discipline into a
|
|
75
|
+
* machine-enforced gate.
|
|
76
|
+
*
|
|
77
|
+
* NOTE: processor implementation (parsing staged paths + scanning the
|
|
78
|
+
* commit message buffer in the prepare-commit-msg or commit-msg hook)
|
|
79
|
+
* is intentionally left to a follow-up — this file only declares the
|
|
80
|
+
* shape so policy.json validation accepts the new rule today.
|
|
81
|
+
*/
|
|
82
|
+
export const CommitMessageRequiredSchema = z.object({
|
|
83
|
+
id: z.string().min(1).describe("Stable identifier for audit-log attribution"),
|
|
84
|
+
paths: z
|
|
85
|
+
.array(z.string())
|
|
86
|
+
.min(1)
|
|
87
|
+
.describe("Glob patterns of staged files that trigger this rule (e.g. server/deploy.sh)"),
|
|
88
|
+
pattern: z
|
|
89
|
+
.string()
|
|
90
|
+
.min(1)
|
|
91
|
+
.describe("ERE regex the commit message MUST match for the commit to proceed"),
|
|
92
|
+
severity: z.enum(["block", "warn"]).default("block"),
|
|
93
|
+
description: z.string().optional(),
|
|
94
|
+
});
|
|
61
95
|
/**
|
|
62
96
|
* A documented escape hatch for the hook. Beats undocumented `touch` /
|
|
63
97
|
* `--no-verify` workarounds. Bypass token requires a reason and lives in
|
|
@@ -82,6 +116,7 @@ export const PolicySchema = z.object({
|
|
|
82
116
|
secret_patterns: z.array(SecretPatternSchema).default([]),
|
|
83
117
|
doc_coverage: z.array(DocCoverageSchema).default([]),
|
|
84
118
|
deploy_verify_hosts: z.array(DeployVerifyHostSchema).default([]),
|
|
119
|
+
commit_message_required: z.array(CommitMessageRequiredSchema).default([]),
|
|
85
120
|
bypass_tokens: z.array(BypassTokenSchema).default([]),
|
|
86
121
|
});
|
|
87
122
|
export function validatePolicy(raw) {
|
|
@@ -165,6 +200,11 @@ export function formatPolicySummary(policy) {
|
|
|
165
200
|
lines.push(` - ${h.host} → probe within ${h.within_seconds}s: ${h.require_probe}`);
|
|
166
201
|
}
|
|
167
202
|
lines.push("");
|
|
203
|
+
lines.push(`Commit-message-required rules: ${policy.commit_message_required.length}`);
|
|
204
|
+
for (const r of policy.commit_message_required) {
|
|
205
|
+
lines.push(` - [${r.severity}] ${r.id} → paths ${r.paths.join(", ")} must match /${r.pattern}/`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("");
|
|
168
208
|
lines.push(`Bypass tokens: ${policy.bypass_tokens.length}`);
|
|
169
209
|
for (const b of policy.bypass_tokens) {
|
|
170
210
|
lines.push(` - ${b.id} → TTL ${b.ttl_seconds}s, reason ≥ ${b.requires_reason_min_length} chars`);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools Manifest — single source of truth for the MCP tool catalog.
|
|
3
|
+
*
|
|
4
|
+
* Why: the info panel in the VS Code extension (and the README) historically
|
|
5
|
+
* hardcoded the tool count ("Active on all 17 MCP tools"). When new tools
|
|
6
|
+
* shipped (drift_status was the 21st), those displays silently drifted.
|
|
7
|
+
*
|
|
8
|
+
* Fix: every tool exposed via `server.tool(...)` in `index.ts` MUST appear
|
|
9
|
+
* in `ALL_TOOLS` below. A regression test in `tests/tools-manifest.test.ts`
|
|
10
|
+
* counts `^server.tool(` lines in `index.ts` and asserts the count matches
|
|
11
|
+
* `ALL_TOOLS.length`. If you add or remove a tool, this list + the test
|
|
12
|
+
* fail together — no silent drift possible.
|
|
13
|
+
*
|
|
14
|
+
* Consumers:
|
|
15
|
+
* - `index.ts` writes `~/.contextengine/server-meta.json` on startup with
|
|
16
|
+
* `{ toolCount, premiumCount, freeCount, version, generatedAt }` so the
|
|
17
|
+
* VS Code extension can read it without needing an active MCP session.
|
|
18
|
+
* - `activation.ts` imports `PREMIUM_TOOL_NAMES` (the subset that requires
|
|
19
|
+
* a PRO license).
|
|
20
|
+
*
|
|
21
|
+
* @module tools-manifest
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Every tool name registered on the MCP server, in registration order.
|
|
25
|
+
* Order is not load-bearing — kept stable for easier diffs.
|
|
26
|
+
*/
|
|
27
|
+
export declare const ALL_TOOLS: readonly ["search_context", "list_sources", "read_source", "reindex", "list_projects", "check_ports", "run_audit", "score_project", "save_session", "load_session", "list_sessions", "delete_session", "audit_verify", "drift_status", "end_session", "save_learning", "list_learnings", "delete_learning", "import_learnings", "activate", "activation_status"];
|
|
28
|
+
/**
|
|
29
|
+
* The 4 tools gated behind PRO activation. Subset of `ALL_TOOLS`.
|
|
30
|
+
* Must match `PREMIUM_TOOLS` in `src/activation.ts` (asserted by test).
|
|
31
|
+
*/
|
|
32
|
+
export declare const PREMIUM_TOOL_NAMES: readonly ["score_project", "run_audit", "check_ports", "list_projects"];
|
|
33
|
+
/** Total count — what users see as "Active on all N MCP tools". */
|
|
34
|
+
export declare const TOOL_COUNT: 21;
|
|
35
|
+
/** Free-tier tool count — everything except `PREMIUM_TOOL_NAMES`. */
|
|
36
|
+
export declare const FREE_TOOL_COUNT: number;
|
|
37
|
+
//# sourceMappingURL=tools-manifest.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools Manifest — single source of truth for the MCP tool catalog.
|
|
3
|
+
*
|
|
4
|
+
* Why: the info panel in the VS Code extension (and the README) historically
|
|
5
|
+
* hardcoded the tool count ("Active on all 17 MCP tools"). When new tools
|
|
6
|
+
* shipped (drift_status was the 21st), those displays silently drifted.
|
|
7
|
+
*
|
|
8
|
+
* Fix: every tool exposed via `server.tool(...)` in `index.ts` MUST appear
|
|
9
|
+
* in `ALL_TOOLS` below. A regression test in `tests/tools-manifest.test.ts`
|
|
10
|
+
* counts `^server.tool(` lines in `index.ts` and asserts the count matches
|
|
11
|
+
* `ALL_TOOLS.length`. If you add or remove a tool, this list + the test
|
|
12
|
+
* fail together — no silent drift possible.
|
|
13
|
+
*
|
|
14
|
+
* Consumers:
|
|
15
|
+
* - `index.ts` writes `~/.contextengine/server-meta.json` on startup with
|
|
16
|
+
* `{ toolCount, premiumCount, freeCount, version, generatedAt }` so the
|
|
17
|
+
* VS Code extension can read it without needing an active MCP session.
|
|
18
|
+
* - `activation.ts` imports `PREMIUM_TOOL_NAMES` (the subset that requires
|
|
19
|
+
* a PRO license).
|
|
20
|
+
*
|
|
21
|
+
* @module tools-manifest
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Every tool name registered on the MCP server, in registration order.
|
|
25
|
+
* Order is not load-bearing — kept stable for easier diffs.
|
|
26
|
+
*/
|
|
27
|
+
export const ALL_TOOLS = [
|
|
28
|
+
"search_context",
|
|
29
|
+
"list_sources",
|
|
30
|
+
"read_source",
|
|
31
|
+
"reindex",
|
|
32
|
+
"list_projects",
|
|
33
|
+
"check_ports",
|
|
34
|
+
"run_audit",
|
|
35
|
+
"score_project",
|
|
36
|
+
"save_session",
|
|
37
|
+
"load_session",
|
|
38
|
+
"list_sessions",
|
|
39
|
+
"delete_session",
|
|
40
|
+
"audit_verify",
|
|
41
|
+
"drift_status",
|
|
42
|
+
"end_session",
|
|
43
|
+
"save_learning",
|
|
44
|
+
"list_learnings",
|
|
45
|
+
"delete_learning",
|
|
46
|
+
"import_learnings",
|
|
47
|
+
"activate",
|
|
48
|
+
"activation_status",
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* The 4 tools gated behind PRO activation. Subset of `ALL_TOOLS`.
|
|
52
|
+
* Must match `PREMIUM_TOOLS` in `src/activation.ts` (asserted by test).
|
|
53
|
+
*/
|
|
54
|
+
export const PREMIUM_TOOL_NAMES = [
|
|
55
|
+
"score_project",
|
|
56
|
+
"run_audit",
|
|
57
|
+
"check_ports",
|
|
58
|
+
"list_projects",
|
|
59
|
+
];
|
|
60
|
+
/** Total count — what users see as "Active on all N MCP tools". */
|
|
61
|
+
export const TOOL_COUNT = ALL_TOOLS.length;
|
|
62
|
+
/** Free-tier tool count — everything except `PREMIUM_TOOL_NAMES`. */
|
|
63
|
+
export const FREE_TOOL_COUNT = ALL_TOOLS.length - PREMIUM_TOOL_NAMES.length;
|
|
64
|
+
//# sourceMappingURL=tools-manifest.js.map
|