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.
- package/README.md +99 -5
- package/package.json +15 -3
- package/src/cli/index.js +314 -1
- package/src/core/auth.js +341 -0
- package/src/core/compliance.js +1 -1
- package/src/core/crypto.js +158 -0
- package/src/core/engine.js +62 -0
- package/src/core/policy.js +719 -0
- package/src/core/sso.js +386 -0
- package/src/core/storage.js +23 -4
- package/src/core/telemetry.js +281 -0
- package/src/dashboard/index.html +338 -0
- package/src/mcp/http-server.js +248 -3
- package/src/mcp/server.js +172 -1
package/src/mcp/http-server.js
CHANGED
|
@@ -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 = "
|
|
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:
|
|
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:
|
|
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 = "
|
|
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;
|