speclock 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * SpecLock Tracking Module
3
+ * Change logging, file event handling, event management.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import {
12
+ nowIso,
13
+ newId,
14
+ writeBrain,
15
+ appendEvent,
16
+ bumpEvents,
17
+ speclockDir,
18
+ addRecentChange,
19
+ addRevert,
20
+ } from "./storage.js";
21
+ import { captureDiff } from "./git.js";
22
+ import { ensureInit } from "./memory.js";
23
+
24
+ // --- Internal helpers ---
25
+
26
+ function recordEvent(root, brain, event) {
27
+ bumpEvents(brain, event.eventId);
28
+ appendEvent(root, event);
29
+ writeBrain(root, brain);
30
+ }
31
+
32
+ function writePatch(root, eventId, content) {
33
+ const patchPath = path.join(
34
+ speclockDir(root),
35
+ "patches",
36
+ `${eventId}.patch`
37
+ );
38
+ fs.writeFileSync(patchPath, content);
39
+ return path.join(".speclock", "patches", `${eventId}.patch`);
40
+ }
41
+
42
+ // --- Core functions ---
43
+
44
+ export function logChange(root, summary, files = []) {
45
+ const brain = ensureInit(root);
46
+ const eventId = newId("evt");
47
+ let patchPath = "";
48
+ if (brain.facts.repo.hasGit) {
49
+ const diff = captureDiff(root);
50
+ if (diff && diff.trim().length > 0) {
51
+ patchPath = writePatch(root, eventId, diff);
52
+ }
53
+ }
54
+ const event = {
55
+ eventId,
56
+ type: "manual_change",
57
+ at: nowIso(),
58
+ files,
59
+ summary,
60
+ patchPath,
61
+ };
62
+ addRecentChange(brain, {
63
+ eventId,
64
+ summary,
65
+ files,
66
+ at: event.at,
67
+ });
68
+ recordEvent(root, brain, event);
69
+ return { brain, eventId };
70
+ }
71
+
72
+ export function handleFileEvent(root, brain, type, filePath) {
73
+ const eventId = newId("evt");
74
+ const rel = path.relative(root, filePath);
75
+ let patchPath = "";
76
+ if (brain.facts.repo.hasGit) {
77
+ const diff = captureDiff(root);
78
+ const patchContent =
79
+ diff && diff.trim().length > 0 ? diff : "(no diff available)";
80
+ patchPath = writePatch(root, eventId, patchContent);
81
+ }
82
+ const summary = `${type.replace("_", " ")}: ${rel}`;
83
+ const event = {
84
+ eventId,
85
+ type,
86
+ at: nowIso(),
87
+ files: [rel],
88
+ summary,
89
+ patchPath,
90
+ };
91
+ addRecentChange(brain, {
92
+ eventId,
93
+ summary,
94
+ files: [rel],
95
+ at: event.at,
96
+ });
97
+ recordEvent(root, brain, event);
98
+ }
@@ -30,6 +30,11 @@ import {
30
30
  auditStagedFiles,
31
31
  verifyAuditChain,
32
32
  exportCompliance,
33
+ enforceConflictCheck,
34
+ setEnforcementMode,
35
+ overrideLock,
36
+ getOverrideHistory,
37
+ semanticAudit,
33
38
  } from "../core/engine.js";
34
39
  import { generateContext, generateContextPack } from "../core/context.js";
35
40
  import {
@@ -46,9 +51,21 @@ import {
46
51
  createTag,
47
52
  getDiffSummary,
48
53
  } from "../core/git.js";
54
+ import {
55
+ isAuthEnabled,
56
+ validateApiKey,
57
+ checkPermission,
58
+ createApiKey,
59
+ rotateApiKey,
60
+ revokeApiKey,
61
+ listApiKeys,
62
+ enableAuth,
63
+ disableAuth,
64
+ TOOL_PERMISSIONS,
65
+ } from "../core/auth.js";
49
66
 
50
67
  const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
51
- const VERSION = "2.1.1";
68
+ const VERSION = "3.0.0";
52
69
  const AUTHOR = "Sandeep Roy";
53
70
  const START_TIME = Date.now();
54
71
 
@@ -219,11 +236,14 @@ function createSpecLockServer() {
219
236
  return { content: [{ type: "text", text: events.length ? JSON.stringify(events, null, 2) : "No matching events." }] };
220
237
  });
221
238
 
222
- // Tool 12: speclock_check_conflict
223
- server.tool("speclock_check_conflict", "Check if a proposed action conflicts with any active SpecLock.", { proposedAction: z.string().min(1).describe("Description of the action") }, async ({ proposedAction }) => {
239
+ // Tool 12: speclock_check_conflict (v2.5: uses enforcer)
240
+ server.tool("speclock_check_conflict", "Check if a proposed action conflicts with any active SpecLock. In hard mode, blocks above threshold.", { proposedAction: z.string().min(1).describe("Description of the action") }, async ({ proposedAction }) => {
224
241
  ensureInit(PROJECT_ROOT);
225
- const result = await checkConflictAsync(PROJECT_ROOT, proposedAction);
226
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
242
+ const result = enforceConflictCheck(PROJECT_ROOT, proposedAction);
243
+ if (result.blocked) {
244
+ return { content: [{ type: "text", text: result.analysis }], isError: true };
245
+ }
246
+ return { content: [{ type: "text", text: result.analysis }] };
227
247
  });
228
248
 
229
249
  // Tool 13: speclock_session_briefing
@@ -354,6 +374,35 @@ function createSpecLockServer() {
354
374
  return { content: [{ type: "text", text: output }] };
355
375
  });
356
376
 
377
+ // Tool 25: speclock_set_enforcement (v2.5)
378
+ server.tool("speclock_set_enforcement", "Set enforcement mode: advisory (warn) or hard (block).", { mode: z.enum(["advisory", "hard"]).describe("Enforcement mode"), blockThreshold: z.number().int().min(0).max(100).optional().default(70).describe("Block threshold %") }, async ({ mode, blockThreshold }) => {
379
+ const result = setEnforcementMode(PROJECT_ROOT, mode, { blockThreshold });
380
+ if (!result.success) return { content: [{ type: "text", text: result.error }], isError: true };
381
+ return { content: [{ type: "text", text: `Enforcement: ${mode} (threshold: ${result.config.blockThreshold}%)` }] };
382
+ });
383
+
384
+ // Tool 26: speclock_override_lock (v2.5)
385
+ server.tool("speclock_override_lock", "Override a lock with justification. Logged to audit trail.", { lockId: z.string().min(1), action: z.string().min(1), reason: z.string().min(1) }, async ({ lockId, action, reason }) => {
386
+ const result = overrideLock(PROJECT_ROOT, lockId, action, reason);
387
+ if (!result.success) return { content: [{ type: "text", text: result.error }], isError: true };
388
+ const msg = result.escalated ? `\n${result.escalationMessage}` : "";
389
+ return { content: [{ type: "text", text: `Override: "${result.lockText}" (${result.overrideCount}x)${msg}` }] };
390
+ });
391
+
392
+ // Tool 27: speclock_semantic_audit (v2.5)
393
+ server.tool("speclock_semantic_audit", "Semantic pre-commit: analyzes code changes vs locks.", {}, async () => {
394
+ const result = semanticAudit(PROJECT_ROOT);
395
+ return { content: [{ type: "text", text: result.message }], isError: result.blocked || false };
396
+ });
397
+
398
+ // Tool 28: speclock_override_history (v2.5)
399
+ server.tool("speclock_override_history", "Show lock override history.", { lockId: z.string().optional() }, async ({ lockId }) => {
400
+ const result = getOverrideHistory(PROJECT_ROOT, lockId);
401
+ if (result.total === 0) return { content: [{ type: "text", text: "No overrides recorded." }] };
402
+ const lines = result.overrides.map(o => `[${o.at.substring(0,19)}] "${o.lockText}" — ${o.reason}`).join("\n");
403
+ return { content: [{ type: "text", text: `Overrides (${result.total}):\n${lines}` }] };
404
+ });
405
+
357
406
  return server;
358
407
  }
359
408
 
@@ -366,6 +415,16 @@ app.options("*", (req, res) => {
366
415
  res.writeHead(204).end();
367
416
  });
368
417
 
418
+ // --- Auth middleware helper ---
419
+ function authenticateRequest(req) {
420
+ if (!isAuthEnabled(PROJECT_ROOT)) {
421
+ return { valid: true, role: "admin", authEnabled: false };
422
+ }
423
+ const authHeader = req.headers["authorization"] || "";
424
+ const rawKey = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
425
+ return validateApiKey(PROJECT_ROOT, rawKey);
426
+ }
427
+
369
428
  app.post("/mcp", async (req, res) => {
370
429
  setCorsHeaders(res);
371
430
 
@@ -389,6 +448,28 @@ app.post("/mcp", async (req, res) => {
389
448
  });
390
449
  }
391
450
 
451
+ // Authentication (v3.0)
452
+ const auth = authenticateRequest(req);
453
+ if (!auth.valid) {
454
+ return res.status(401).json({
455
+ jsonrpc: "2.0",
456
+ error: { code: -32000, message: auth.error || "Authentication required." },
457
+ id: null,
458
+ });
459
+ }
460
+
461
+ // RBAC check — extract tool name from JSON-RPC body for permission check
462
+ if (auth.authEnabled && req.body && req.body.method === "tools/call") {
463
+ const toolName = req.body.params?.name;
464
+ if (toolName && !checkPermission(auth.role, toolName)) {
465
+ return res.status(403).json({
466
+ jsonrpc: "2.0",
467
+ error: { code: -32000, message: `Permission denied. Role "${auth.role}" cannot access "${toolName}". Required: ${TOOL_PERMISSIONS[toolName] || "admin"}` },
468
+ id: req.body.id || null,
469
+ });
470
+ }
471
+ }
472
+
392
473
  const server = createSpecLockServer();
393
474
  try {
394
475
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
@@ -406,6 +487,51 @@ app.post("/mcp", async (req, res) => {
406
487
  }
407
488
  });
408
489
 
490
+ // --- Auth management endpoint (v3.0) ---
491
+ app.post("/auth", async (req, res) => {
492
+ setCorsHeaders(res);
493
+ const auth = authenticateRequest(req);
494
+
495
+ const { action } = req.body || {};
496
+
497
+ // Creating the first key doesn't require auth (bootstrap)
498
+ if (action === "create-key" && !isAuthEnabled(PROJECT_ROOT)) {
499
+ const { role, name } = req.body;
500
+ const result = createApiKey(PROJECT_ROOT, role || "admin", name || "");
501
+ return res.json(result);
502
+ }
503
+
504
+ // All other auth actions require admin role
505
+ if (auth.authEnabled && (!auth.valid || auth.role !== "admin")) {
506
+ return res.status(auth.valid ? 403 : 401).json({
507
+ error: auth.valid ? "Admin role required for auth management." : auth.error,
508
+ });
509
+ }
510
+
511
+ switch (action) {
512
+ case "create-key": {
513
+ const { role, name } = req.body;
514
+ return res.json(createApiKey(PROJECT_ROOT, role || "developer", name || ""));
515
+ }
516
+ case "rotate-key": {
517
+ const { keyId } = req.body;
518
+ return res.json(rotateApiKey(PROJECT_ROOT, keyId));
519
+ }
520
+ case "revoke-key": {
521
+ const { keyId, reason } = req.body;
522
+ return res.json(revokeApiKey(PROJECT_ROOT, keyId, reason || "manual"));
523
+ }
524
+ case "list-keys":
525
+ return res.json(listApiKeys(PROJECT_ROOT));
526
+ case "enable":
527
+ return res.json(enableAuth(PROJECT_ROOT));
528
+ case "disable":
529
+ return res.json(disableAuth(PROJECT_ROOT));
530
+ default:
531
+ return res.status(400).json({ error: `Unknown auth action: "${action}". Valid: create-key, rotate-key, revoke-key, list-keys, enable, disable` });
532
+ }
533
+ });
534
+
409
535
  app.get("/mcp", async (req, res) => {
410
536
  setCorsHeaders(res);
411
537
  res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
@@ -431,8 +557,9 @@ app.get("/health", (req, res) => {
431
557
  status: "healthy",
432
558
  version: VERSION,
433
559
  uptime: Math.floor((Date.now() - START_TIME) / 1000),
434
- tools: 24,
560
+ tools: 28,
435
561
  auditChain: auditStatus,
562
+ authEnabled: isAuthEnabled(PROJECT_ROOT),
436
563
  rateLimit: { limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS },
437
564
  });
438
565
  });
@@ -445,7 +572,7 @@ app.get("/", (req, res) => {
445
572
  version: VERSION,
446
573
  author: AUTHOR,
447
574
  description: "AI Continuity Engine with enterprise audit, compliance, and enforcement",
448
- tools: 24,
575
+ tools: 28,
449
576
  mcp_endpoint: "/mcp",
450
577
  health_endpoint: "/health",
451
578
  npm: "https://www.npmjs.com/package/speclock",
package/src/mcp/server.js CHANGED
@@ -27,6 +27,12 @@ import {
27
27
  exportCompliance,
28
28
  checkLimits,
29
29
  getLicenseInfo,
30
+ enforceConflictCheck,
31
+ setEnforcementMode,
32
+ overrideLock,
33
+ getOverrideHistory,
34
+ getEnforcementConfig,
35
+ semanticAudit,
30
36
  } from "../core/engine.js";
31
37
  import { generateContext, generateContextPack } from "../core/context.js";
32
38
  import {
@@ -43,6 +49,29 @@ import {
43
49
  createTag,
44
50
  getDiffSummary,
45
51
  } from "../core/git.js";
52
+ import {
53
+ isAuthEnabled,
54
+ validateApiKey,
55
+ checkPermission,
56
+ } from "../core/auth.js";
57
+
58
+ // --- Auth via env var (v3.0) ---
59
+ function getAuthRole() {
60
+ if (!isAuthEnabled(PROJECT_ROOT)) return "admin";
61
+ const key = process.env.SPECLOCK_API_KEY;
62
+ if (!key) return "admin"; // No key env var = local use, allow all
63
+ const result = validateApiKey(PROJECT_ROOT, key);
64
+ return result.valid ? result.role : null;
65
+ }
66
+
67
+ function requirePermission(toolName) {
68
+ const role = getAuthRole();
69
+ if (!role) return { allowed: false, error: "Invalid SPECLOCK_API_KEY." };
70
+ if (!checkPermission(role, toolName)) {
71
+ return { allowed: false, error: `Permission denied. Role "${role}" cannot access "${toolName}".` };
72
+ }
73
+ return { allowed: true, role };
74
+ }
46
75
 
47
76
  // --- Project root resolution ---
48
77
  function parseArgs(argv) {
@@ -61,7 +90,7 @@ const PROJECT_ROOT =
61
90
  args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
62
91
 
63
92
  // --- MCP Server ---
64
- const VERSION = "2.1.1";
93
+ const VERSION = "3.0.0";
65
94
  const AUTHOR = "Sandeep Roy";
66
95
 
67
96
  const server = new McpServer(
@@ -421,10 +450,10 @@ server.tool(
421
450
  // CONTINUITY PROTECTION TOOLS
422
451
  // ========================================
423
452
 
424
- // Tool 12: speclock_check_conflict
453
+ // Tool 12: speclock_check_conflict (v2.5: uses enforcer — hard mode returns isError)
425
454
  server.tool(
426
455
  "speclock_check_conflict",
427
- "Check if a proposed action conflicts with any active SpecLock. Use before making significant changes.",
456
+ "Check if a proposed action conflicts with any active SpecLock. Use before making significant changes. In hard enforcement mode, conflicts above the threshold will BLOCK the action (isError: true).",
428
457
  {
429
458
  proposedAction: z
430
459
  .string()
@@ -432,7 +461,28 @@ server.tool(
432
461
  .describe("Description of the action you plan to take"),
433
462
  },
434
463
  async ({ proposedAction }) => {
435
- const result = await checkConflictAsync(PROJECT_ROOT, proposedAction);
464
+ // Try LLM-enhanced check first, fall back to heuristic enforcer
465
+ let result;
466
+ try {
467
+ const { llmCheckConflict } = await import("../core/llm-checker.js");
468
+ const llmResult = await llmCheckConflict(PROJECT_ROOT, proposedAction);
469
+ if (llmResult) {
470
+ result = llmResult;
471
+ }
472
+ } catch (_) {}
473
+
474
+ if (!result) {
475
+ result = enforceConflictCheck(PROJECT_ROOT, proposedAction);
476
+ }
477
+
478
+ // In hard mode with blocking conflict, return isError: true
479
+ if (result.blocked) {
480
+ return {
481
+ content: [{ type: "text", text: result.analysis }],
482
+ isError: true,
483
+ };
484
+ }
485
+
436
486
  return {
437
487
  content: [{ type: "text", text: result.analysis }],
438
488
  };
@@ -958,6 +1008,158 @@ server.tool(
958
1008
  }
959
1009
  );
960
1010
 
1011
+ // ========================================
1012
+ // HARD ENFORCEMENT TOOLS (v2.5)
1013
+ // ========================================
1014
+
1015
+ // Tool 25: speclock_set_enforcement
1016
+ server.tool(
1017
+ "speclock_set_enforcement",
1018
+ "Set the enforcement mode for this project. 'advisory' (default) warns about conflicts. 'hard' blocks actions that violate locks above the confidence threshold — the AI cannot proceed.",
1019
+ {
1020
+ mode: z
1021
+ .enum(["advisory", "hard"])
1022
+ .describe("Enforcement mode: advisory (warn) or hard (block)"),
1023
+ blockThreshold: z
1024
+ .number()
1025
+ .int()
1026
+ .min(0)
1027
+ .max(100)
1028
+ .optional()
1029
+ .default(70)
1030
+ .describe("Minimum confidence % to block in hard mode (default: 70)"),
1031
+ allowOverride: z
1032
+ .boolean()
1033
+ .optional()
1034
+ .default(true)
1035
+ .describe("Whether lock overrides are permitted"),
1036
+ },
1037
+ async ({ mode, blockThreshold, allowOverride }) => {
1038
+ const result = setEnforcementMode(PROJECT_ROOT, mode, { blockThreshold, allowOverride });
1039
+ if (!result.success) {
1040
+ return {
1041
+ content: [{ type: "text", text: result.error }],
1042
+ isError: true,
1043
+ };
1044
+ }
1045
+ return {
1046
+ content: [
1047
+ {
1048
+ type: "text",
1049
+ text: `Enforcement mode set to **${mode}**. Threshold: ${result.config.blockThreshold}%. Overrides: ${result.config.allowOverride ? "allowed" : "disabled"}.`,
1050
+ },
1051
+ ],
1052
+ };
1053
+ }
1054
+ );
1055
+
1056
+ // Tool 26: speclock_override_lock
1057
+ server.tool(
1058
+ "speclock_override_lock",
1059
+ "Override a lock for a specific action. Requires a reason which is logged to the audit trail. Use when a locked action must proceed with justification. Triggers escalation after repeated overrides.",
1060
+ {
1061
+ lockId: z.string().min(1).describe("The lock ID to override"),
1062
+ action: z.string().min(1).describe("The action that conflicts with the lock"),
1063
+ reason: z.string().min(1).describe("Justification for the override"),
1064
+ },
1065
+ async ({ lockId, action, reason }) => {
1066
+ const result = overrideLock(PROJECT_ROOT, lockId, action, reason);
1067
+ if (!result.success) {
1068
+ return {
1069
+ content: [{ type: "text", text: result.error }],
1070
+ isError: true,
1071
+ };
1072
+ }
1073
+
1074
+ const parts = [
1075
+ `Lock overridden: "${result.lockText}"`,
1076
+ `Override count: ${result.overrideCount}`,
1077
+ `Reason: ${reason}`,
1078
+ ];
1079
+
1080
+ if (result.escalated) {
1081
+ parts.push("", result.escalationMessage);
1082
+ }
1083
+
1084
+ return {
1085
+ content: [{ type: "text", text: parts.join("\n") }],
1086
+ };
1087
+ }
1088
+ );
1089
+
1090
+ // Tool 27: speclock_semantic_audit
1091
+ server.tool(
1092
+ "speclock_semantic_audit",
1093
+ "Run semantic pre-commit audit. Parses the staged git diff, analyzes actual code changes against active locks using semantic analysis. Much more powerful than filename-only audit — catches violations in code content.",
1094
+ {},
1095
+ async () => {
1096
+ const result = semanticAudit(PROJECT_ROOT);
1097
+
1098
+ if (result.passed) {
1099
+ return {
1100
+ content: [{ type: "text", text: result.message }],
1101
+ };
1102
+ }
1103
+
1104
+ const formatted = result.violations
1105
+ .map((v) => {
1106
+ const lines = [`- [${v.level}] **${v.file}** (confidence: ${v.confidence}%)`];
1107
+ lines.push(` Lock: "${v.lockText}"`);
1108
+ lines.push(` Reason: ${v.reason}`);
1109
+ if (v.addedLines) lines.push(` Changes: +${v.addedLines} / -${v.removedLines} lines`);
1110
+ return lines.join("\n");
1111
+ })
1112
+ .join("\n\n");
1113
+
1114
+ return {
1115
+ content: [
1116
+ {
1117
+ type: "text",
1118
+ text: `## Semantic Audit Result\n\nMode: ${result.mode} | Threshold: ${result.threshold}%\n\n${formatted}\n\n${result.message}`,
1119
+ },
1120
+ ],
1121
+ isError: result.blocked || false,
1122
+ };
1123
+ }
1124
+ );
1125
+
1126
+ // Tool 28: speclock_override_history
1127
+ server.tool(
1128
+ "speclock_override_history",
1129
+ "Get the history of lock overrides. Shows which locks have been overridden, by whom, and the reasons given. Useful for audit review and identifying locks that may need updating.",
1130
+ {
1131
+ lockId: z
1132
+ .string()
1133
+ .optional()
1134
+ .describe("Filter by specific lock ID. Omit to see all overrides."),
1135
+ },
1136
+ async ({ lockId }) => {
1137
+ const result = getOverrideHistory(PROJECT_ROOT, lockId);
1138
+
1139
+ if (result.total === 0) {
1140
+ return {
1141
+ content: [{ type: "text", text: "No overrides recorded." }],
1142
+ };
1143
+ }
1144
+
1145
+ const formatted = result.overrides
1146
+ .map(
1147
+ (o) =>
1148
+ `- [${o.at.substring(0, 19)}] Lock: "${o.lockText}" (${o.lockId})\n Action: ${o.action}\n Reason: ${o.reason}`
1149
+ )
1150
+ .join("\n\n");
1151
+
1152
+ return {
1153
+ content: [
1154
+ {
1155
+ type: "text",
1156
+ text: `## Override History (${result.total})\n\n${formatted}`,
1157
+ },
1158
+ ],
1159
+ };
1160
+ }
1161
+ );
1162
+
961
1163
  // --- Smithery sandbox export ---
962
1164
  export default function createSandboxServer() {
963
1165
  return server;