speclock 2.5.0 → 3.5.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.
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import os from "os";
8
+ import fs from "fs";
8
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
11
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -51,9 +52,46 @@ import {
51
52
  createTag,
52
53
  getDiffSummary,
53
54
  } from "../core/git.js";
55
+ import {
56
+ isAuthEnabled,
57
+ validateApiKey,
58
+ checkPermission,
59
+ createApiKey,
60
+ rotateApiKey,
61
+ revokeApiKey,
62
+ listApiKeys,
63
+ enableAuth,
64
+ disableAuth,
65
+ TOOL_PERMISSIONS,
66
+ } from "../core/auth.js";
67
+ import { isEncryptionEnabled } from "../core/crypto.js";
68
+ import {
69
+ evaluatePolicy,
70
+ listPolicyRules,
71
+ addPolicyRule,
72
+ removePolicyRule,
73
+ initPolicy,
74
+ exportPolicy,
75
+ importPolicy,
76
+ } from "../core/policy.js";
77
+ import {
78
+ isTelemetryEnabled,
79
+ trackToolUsage,
80
+ getTelemetrySummary,
81
+ } from "../core/telemetry.js";
82
+ import {
83
+ isSSOEnabled,
84
+ getAuthorizationUrl,
85
+ handleCallback as ssoHandleCallback,
86
+ validateSession,
87
+ revokeSession,
88
+ listSessions,
89
+ } from "../core/sso.js";
90
+ import { fileURLToPath } from "url";
91
+ import _path from "path";
54
92
 
55
93
  const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
56
- const VERSION = "2.5.0";
94
+ const VERSION = "3.5.0";
57
95
  const AUTHOR = "Sandeep Roy";
58
96
  const START_TIME = Date.now();
59
97
 
@@ -403,6 +441,16 @@ app.options("*", (req, res) => {
403
441
  res.writeHead(204).end();
404
442
  });
405
443
 
444
+ // --- Auth middleware helper ---
445
+ function authenticateRequest(req) {
446
+ if (!isAuthEnabled(PROJECT_ROOT)) {
447
+ return { valid: true, role: "admin", authEnabled: false };
448
+ }
449
+ const authHeader = req.headers["authorization"] || "";
450
+ const rawKey = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
451
+ return validateApiKey(PROJECT_ROOT, rawKey);
452
+ }
453
+
406
454
  app.post("/mcp", async (req, res) => {
407
455
  setCorsHeaders(res);
408
456
 
@@ -426,6 +474,28 @@ app.post("/mcp", async (req, res) => {
426
474
  });
427
475
  }
428
476
 
477
+ // Authentication (v3.0)
478
+ const auth = authenticateRequest(req);
479
+ if (!auth.valid) {
480
+ return res.status(401).json({
481
+ jsonrpc: "2.0",
482
+ error: { code: -32000, message: auth.error || "Authentication required." },
483
+ id: null,
484
+ });
485
+ }
486
+
487
+ // RBAC check — extract tool name from JSON-RPC body for permission check
488
+ if (auth.authEnabled && req.body && req.body.method === "tools/call") {
489
+ const toolName = req.body.params?.name;
490
+ if (toolName && !checkPermission(auth.role, toolName)) {
491
+ return res.status(403).json({
492
+ jsonrpc: "2.0",
493
+ error: { code: -32000, message: `Permission denied. Role "${auth.role}" cannot access "${toolName}". Required: ${TOOL_PERMISSIONS[toolName] || "admin"}` },
494
+ id: req.body.id || null,
495
+ });
496
+ }
497
+ }
498
+
429
499
  const server = createSpecLockServer();
430
500
  try {
431
501
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
@@ -443,6 +513,51 @@ app.post("/mcp", async (req, res) => {
443
513
  }
444
514
  });
445
515
 
516
+ // --- Auth management endpoint (v3.0) ---
517
+ app.post("/auth", async (req, res) => {
518
+ setCorsHeaders(res);
519
+ const auth = authenticateRequest(req);
520
+
521
+ const { action } = req.body || {};
522
+
523
+ // Creating the first key doesn't require auth (bootstrap)
524
+ if (action === "create-key" && !isAuthEnabled(PROJECT_ROOT)) {
525
+ const { role, name } = req.body;
526
+ const result = createApiKey(PROJECT_ROOT, role || "admin", name || "");
527
+ return res.json(result);
528
+ }
529
+
530
+ // All other auth actions require admin role
531
+ if (auth.authEnabled && (!auth.valid || auth.role !== "admin")) {
532
+ return res.status(auth.valid ? 403 : 401).json({
533
+ error: auth.valid ? "Admin role required for auth management." : auth.error,
534
+ });
535
+ }
536
+
537
+ switch (action) {
538
+ case "create-key": {
539
+ const { role, name } = req.body;
540
+ return res.json(createApiKey(PROJECT_ROOT, role || "developer", name || ""));
541
+ }
542
+ case "rotate-key": {
543
+ const { keyId } = req.body;
544
+ return res.json(rotateApiKey(PROJECT_ROOT, keyId));
545
+ }
546
+ case "revoke-key": {
547
+ const { keyId, reason } = req.body;
548
+ return res.json(revokeApiKey(PROJECT_ROOT, keyId, reason || "manual"));
549
+ }
550
+ case "list-keys":
551
+ return res.json(listApiKeys(PROJECT_ROOT));
552
+ case "enable":
553
+ return res.json(enableAuth(PROJECT_ROOT));
554
+ case "disable":
555
+ return res.json(disableAuth(PROJECT_ROOT));
556
+ default:
557
+ return res.status(400).json({ error: `Unknown auth action: "${action}". Valid: create-key, rotate-key, revoke-key, list-keys, enable, disable` });
558
+ }
559
+ });
560
+
446
561
  app.get("/mcp", async (req, res) => {
447
562
  setCorsHeaders(res);
448
563
  res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
@@ -468,8 +583,9 @@ app.get("/health", (req, res) => {
468
583
  status: "healthy",
469
584
  version: VERSION,
470
585
  uptime: Math.floor((Date.now() - START_TIME) / 1000),
471
- tools: 24,
586
+ tools: 28,
472
587
  auditChain: auditStatus,
588
+ authEnabled: isAuthEnabled(PROJECT_ROOT),
473
589
  rateLimit: { limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS },
474
590
  });
475
591
  });
@@ -482,7 +598,7 @@ app.get("/", (req, res) => {
482
598
  version: VERSION,
483
599
  author: AUTHOR,
484
600
  description: "AI Continuity Engine with enterprise audit, compliance, and enforcement",
485
- tools: 24,
601
+ tools: 28,
486
602
  mcp_endpoint: "/mcp",
487
603
  health_endpoint: "/health",
488
604
  npm: "https://www.npmjs.com/package/speclock",
@@ -490,7 +606,136 @@ app.get("/", (req, res) => {
490
606
  });
491
607
  });
492
608
 
609
+ // ========================================
610
+ // DASHBOARD (v3.5)
611
+ // ========================================
612
+
613
+ // Serve dashboard HTML
614
+ app.get("/dashboard", (req, res) => {
615
+ setCorsHeaders(res);
616
+ const __filename = fileURLToPath(import.meta.url);
617
+ const __dirname = _path.dirname(__filename);
618
+ const htmlPath = _path.join(__dirname, "..", "dashboard", "index.html");
619
+ try {
620
+ const html = fs.readFileSync(htmlPath, "utf-8");
621
+ res.setHeader("Content-Type", "text/html");
622
+ res.end(html);
623
+ } catch {
624
+ res.status(404).end("Dashboard not found.");
625
+ }
626
+ });
627
+
628
+ // Dashboard API: brain data
629
+ app.get("/dashboard/api/brain", (req, res) => {
630
+ setCorsHeaders(res);
631
+ try {
632
+ ensureInit(PROJECT_ROOT);
633
+ const brain = readBrain(PROJECT_ROOT);
634
+ if (!brain) return res.json({});
635
+ // Add metadata for dashboard
636
+ brain._encryption = isEncryptionEnabled();
637
+ brain._authEnabled = isAuthEnabled(PROJECT_ROOT);
638
+ try {
639
+ const keys = listApiKeys(PROJECT_ROOT);
640
+ brain._authKeys = keys.keys.filter(k => k.active).length;
641
+ } catch { brain._authKeys = 0; }
642
+ res.json(brain);
643
+ } catch (e) {
644
+ res.status(500).json({ error: e.message });
645
+ }
646
+ });
647
+
648
+ // Dashboard API: recent events
649
+ app.get("/dashboard/api/events", (req, res) => {
650
+ setCorsHeaders(res);
651
+ try {
652
+ const events = readEvents(PROJECT_ROOT, { limit: 50 });
653
+ res.json({ events });
654
+ } catch {
655
+ res.json({ events: [] });
656
+ }
657
+ });
658
+
659
+ // Dashboard API: telemetry summary
660
+ app.get("/dashboard/api/telemetry", (req, res) => {
661
+ setCorsHeaders(res);
662
+ res.json(getTelemetrySummary(PROJECT_ROOT));
663
+ });
664
+
665
+ // ========================================
666
+ // POLICY-AS-CODE ENDPOINTS (v3.5)
667
+ // ========================================
668
+
669
+ app.get("/policy", (req, res) => {
670
+ setCorsHeaders(res);
671
+ res.json(listPolicyRules(PROJECT_ROOT));
672
+ });
673
+
674
+ app.post("/policy", async (req, res) => {
675
+ setCorsHeaders(res);
676
+ const auth = authenticateRequest(req);
677
+ if (auth.authEnabled && (!auth.valid || !checkPermission(auth.role, "speclock_add_lock"))) {
678
+ return res.status(auth.valid ? 403 : 401).json({ error: "Write permission required." });
679
+ }
680
+
681
+ const { action } = req.body || {};
682
+ switch (action) {
683
+ case "init":
684
+ return res.json(initPolicy(PROJECT_ROOT));
685
+ case "add-rule":
686
+ return res.json(addPolicyRule(PROJECT_ROOT, req.body.rule || {}));
687
+ case "remove-rule":
688
+ return res.json(removePolicyRule(PROJECT_ROOT, req.body.ruleId));
689
+ case "evaluate":
690
+ return res.json(evaluatePolicy(PROJECT_ROOT, req.body.action || {}));
691
+ case "export":
692
+ return res.json(exportPolicy(PROJECT_ROOT));
693
+ case "import":
694
+ return res.json(importPolicy(PROJECT_ROOT, req.body.yaml || "", req.body.mode || "merge"));
695
+ default:
696
+ return res.status(400).json({ error: `Unknown policy action. Valid: init, add-rule, remove-rule, evaluate, export, import` });
697
+ }
698
+ });
699
+
700
+ // ========================================
701
+ // SSO ENDPOINTS (v3.5)
702
+ // ========================================
703
+
704
+ app.get("/auth/sso/login", (req, res) => {
705
+ setCorsHeaders(res);
706
+ if (!isSSOEnabled(PROJECT_ROOT)) {
707
+ return res.status(400).json({ error: "SSO not configured." });
708
+ }
709
+ const result = getAuthorizationUrl(PROJECT_ROOT);
710
+ if (!result.success) return res.status(400).json(result);
711
+ res.redirect(result.url);
712
+ });
713
+
714
+ app.get("/auth/callback", async (req, res) => {
715
+ setCorsHeaders(res);
716
+ const { code, state, error } = req.query || {};
717
+ if (error) return res.status(400).json({ error });
718
+ if (!code || !state) return res.status(400).json({ error: "Missing code or state." });
719
+ const result = await ssoHandleCallback(PROJECT_ROOT, code, state);
720
+ if (!result.success) return res.status(401).json(result);
721
+ res.json({ message: "SSO login successful", ...result });
722
+ });
723
+
724
+ app.get("/auth/sso/sessions", (req, res) => {
725
+ setCorsHeaders(res);
726
+ res.json(listSessions(PROJECT_ROOT));
727
+ });
728
+
729
+ app.post("/auth/sso/logout", (req, res) => {
730
+ setCorsHeaders(res);
731
+ const { sessionId } = req.body || {};
732
+ res.json(revokeSession(PROJECT_ROOT, sessionId));
733
+ });
734
+
735
+ // ========================================
736
+
493
737
  const PORT = parseInt(process.env.PORT || "3000", 10);
494
738
  app.listen(PORT, "0.0.0.0", () => {
495
739
  console.log(`SpecLock MCP HTTP Server v${VERSION} running on port ${PORT} — Developed by ${AUTHOR}`);
740
+ console.log(` Dashboard: http://localhost:${PORT}/dashboard`);
496
741
  });
package/src/mcp/server.js CHANGED
@@ -33,6 +33,16 @@ import {
33
33
  getOverrideHistory,
34
34
  getEnforcementConfig,
35
35
  semanticAudit,
36
+ evaluatePolicy,
37
+ listPolicyRules,
38
+ addPolicyRule,
39
+ removePolicyRule,
40
+ initPolicy,
41
+ exportPolicy,
42
+ importPolicy,
43
+ isTelemetryEnabled,
44
+ getTelemetrySummary,
45
+ trackToolUsage,
36
46
  } from "../core/engine.js";
37
47
  import { generateContext, generateContextPack } from "../core/context.js";
38
48
  import {
@@ -49,6 +59,29 @@ import {
49
59
  createTag,
50
60
  getDiffSummary,
51
61
  } from "../core/git.js";
62
+ import {
63
+ isAuthEnabled,
64
+ validateApiKey,
65
+ checkPermission,
66
+ } from "../core/auth.js";
67
+
68
+ // --- Auth via env var (v3.0) ---
69
+ function getAuthRole() {
70
+ if (!isAuthEnabled(PROJECT_ROOT)) return "admin";
71
+ const key = process.env.SPECLOCK_API_KEY;
72
+ if (!key) return "admin"; // No key env var = local use, allow all
73
+ const result = validateApiKey(PROJECT_ROOT, key);
74
+ return result.valid ? result.role : null;
75
+ }
76
+
77
+ function requirePermission(toolName) {
78
+ const role = getAuthRole();
79
+ if (!role) return { allowed: false, error: "Invalid SPECLOCK_API_KEY." };
80
+ if (!checkPermission(role, toolName)) {
81
+ return { allowed: false, error: `Permission denied. Role "${role}" cannot access "${toolName}".` };
82
+ }
83
+ return { allowed: true, role };
84
+ }
52
85
 
53
86
  // --- Project root resolution ---
54
87
  function parseArgs(argv) {
@@ -67,7 +100,7 @@ const PROJECT_ROOT =
67
100
  args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
68
101
 
69
102
  // --- MCP Server ---
70
- const VERSION = "2.5.0";
103
+ const VERSION = "3.5.0";
71
104
  const AUTHOR = "Sandeep Roy";
72
105
 
73
106
  const server = new McpServer(
@@ -1137,6 +1170,144 @@ server.tool(
1137
1170
  }
1138
1171
  );
1139
1172
 
1173
+ // ========================================
1174
+ // POLICY-AS-CODE TOOLS (v3.5)
1175
+ // ========================================
1176
+
1177
+ // Tool 29: speclock_policy_evaluate
1178
+ server.tool(
1179
+ "speclock_policy_evaluate",
1180
+ "Evaluate policy-as-code rules against a proposed action. Returns violations for any matching rules. Use alongside speclock_check_conflict for comprehensive protection.",
1181
+ {
1182
+ description: z.string().min(1).describe("Description of the action to evaluate"),
1183
+ files: z.array(z.string()).optional().default([]).describe("Files affected by the action"),
1184
+ type: z.enum(["modify", "delete", "create", "export"]).optional().default("modify").describe("Action type"),
1185
+ },
1186
+ async ({ description, files, type }) => {
1187
+ const result = evaluatePolicy(PROJECT_ROOT, { description, text: description, files, type });
1188
+
1189
+ if (result.passed) {
1190
+ return {
1191
+ content: [{ type: "text", text: `Policy check passed. ${result.rulesChecked} rule(s) evaluated, no violations.` }],
1192
+ };
1193
+ }
1194
+
1195
+ const formatted = result.violations
1196
+ .map(v => `- [${v.severity.toUpperCase()}] **${v.ruleName}** (${v.enforce})\n ${v.description}\n Files: ${v.matchedFiles.join(", ") || "(pattern match)"}`)
1197
+ .join("\n\n");
1198
+
1199
+ return {
1200
+ content: [{ type: "text", text: `## Policy Violations (${result.violations.length})\n\n${formatted}` }],
1201
+ isError: result.blocked,
1202
+ };
1203
+ }
1204
+ );
1205
+
1206
+ // Tool 30: speclock_policy_manage
1207
+ server.tool(
1208
+ "speclock_policy_manage",
1209
+ "Manage policy-as-code rules. Actions: list (show all rules), add (create new rule), remove (delete rule), init (create default policy), export (portable YAML).",
1210
+ {
1211
+ action: z.enum(["list", "add", "remove", "init", "export"]).describe("Policy action"),
1212
+ rule: z.object({
1213
+ name: z.string().optional(),
1214
+ description: z.string().optional(),
1215
+ match: z.object({
1216
+ files: z.array(z.string()).optional(),
1217
+ actions: z.array(z.string()).optional(),
1218
+ }).optional(),
1219
+ enforce: z.enum(["block", "warn", "log"]).optional(),
1220
+ severity: z.enum(["critical", "high", "medium", "low"]).optional(),
1221
+ notify: z.array(z.string()).optional(),
1222
+ }).optional().describe("Rule definition (for add action)"),
1223
+ ruleId: z.string().optional().describe("Rule ID (for remove action)"),
1224
+ },
1225
+ async ({ action, rule, ruleId }) => {
1226
+ switch (action) {
1227
+ case "list": {
1228
+ const result = listPolicyRules(PROJECT_ROOT);
1229
+ if (result.total === 0) {
1230
+ return { content: [{ type: "text", text: "No policy rules defined. Use action 'init' to create a default policy." }] };
1231
+ }
1232
+ const formatted = result.rules.map(r =>
1233
+ `- **${r.name}** (${r.id}) [${r.enforce}/${r.severity}]\n Files: ${(r.match?.files || []).join(", ")}\n Actions: ${(r.match?.actions || []).join(", ")}`
1234
+ ).join("\n\n");
1235
+ return { content: [{ type: "text", text: `## Policy Rules (${result.active}/${result.total} active)\n\n${formatted}` }] };
1236
+ }
1237
+ case "add": {
1238
+ if (!rule || !rule.name) {
1239
+ return { content: [{ type: "text", text: "Rule name is required." }], isError: true };
1240
+ }
1241
+ const result = addPolicyRule(PROJECT_ROOT, rule);
1242
+ if (!result.success) return { content: [{ type: "text", text: result.error }], isError: true };
1243
+ return { content: [{ type: "text", text: `Policy rule added: "${result.rule.name}" (${result.ruleId}) [${result.rule.enforce}]` }] };
1244
+ }
1245
+ case "remove": {
1246
+ if (!ruleId) return { content: [{ type: "text", text: "ruleId is required." }], isError: true };
1247
+ const result = removePolicyRule(PROJECT_ROOT, ruleId);
1248
+ if (!result.success) return { content: [{ type: "text", text: result.error }], isError: true };
1249
+ return { content: [{ type: "text", text: `Policy rule removed: "${result.removed.name}"` }] };
1250
+ }
1251
+ case "init": {
1252
+ const result = initPolicy(PROJECT_ROOT);
1253
+ if (!result.success) return { content: [{ type: "text", text: result.error }], isError: true };
1254
+ return { content: [{ type: "text", text: "Policy-as-code initialized. Edit .speclock/policy.yml to add rules." }] };
1255
+ }
1256
+ case "export": {
1257
+ const result = exportPolicy(PROJECT_ROOT);
1258
+ if (!result.success) return { content: [{ type: "text", text: result.error }], isError: true };
1259
+ return { content: [{ type: "text", text: `## Exported Policy\n\n\`\`\`yaml\n${result.yaml}\`\`\`` }] };
1260
+ }
1261
+ default:
1262
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true };
1263
+ }
1264
+ }
1265
+ );
1266
+
1267
+ // ========================================
1268
+ // TELEMETRY TOOLS (v3.5)
1269
+ // ========================================
1270
+
1271
+ // Tool 31: speclock_telemetry
1272
+ server.tool(
1273
+ "speclock_telemetry",
1274
+ "Get telemetry and analytics summary. Shows tool usage counts, conflict rates, response times, and feature adoption. Opt-in only (SPECLOCK_TELEMETRY=true).",
1275
+ {},
1276
+ async () => {
1277
+ const summary = getTelemetrySummary(PROJECT_ROOT);
1278
+ if (!summary.enabled) {
1279
+ return { content: [{ type: "text", text: summary.message }] };
1280
+ }
1281
+
1282
+ const parts = [
1283
+ `## Telemetry Summary`,
1284
+ ``,
1285
+ `Total API calls: **${summary.totalCalls}**`,
1286
+ `Avg response: **${summary.avgResponseMs}ms**`,
1287
+ `Sessions: **${summary.sessions.total}**`,
1288
+ ``,
1289
+ `### Conflicts`,
1290
+ `Total: ${summary.conflicts.total} | Blocked: ${summary.conflicts.blocked} | Advisory: ${summary.conflicts.advisory}`,
1291
+ ];
1292
+
1293
+ if (summary.topTools.length > 0) {
1294
+ parts.push(``, `### Top Tools`);
1295
+ for (const t of summary.topTools.slice(0, 5)) {
1296
+ parts.push(`- ${t.name}: ${t.count} calls (avg ${t.avgMs}ms)`);
1297
+ }
1298
+ }
1299
+
1300
+ if (summary.features.length > 0) {
1301
+ parts.push(``, `### Feature Adoption`);
1302
+ for (const f of summary.features) {
1303
+ parts.push(`- ${f.name}: ${f.count} uses`);
1304
+ }
1305
+ }
1306
+
1307
+ return { content: [{ type: "text", text: parts.join("\n") }] };
1308
+ }
1309
+ );
1310
+
1140
1311
  // --- Smithery sandbox export ---
1141
1312
  export default function createSandboxServer() {
1142
1313
  return server;