speclock 3.0.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.
@@ -0,0 +1,338 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SpecLock Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
10
+ --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
11
+ --green: #22c55e; --red: #ef4444; --yellow: #eab308; --purple: #a855f7;
12
+ }
13
+ * { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
15
+
16
+ /* Header */
17
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; background: var(--surface); border-bottom: 1px solid var(--border); }
18
+ .header h1 { font-size: 20px; font-weight: 700; }
19
+ .header h1 span { color: var(--accent); }
20
+ .header .meta { font-size: 13px; color: var(--muted); }
21
+ .status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
22
+ .status-badge.healthy { background: rgba(34,197,94,0.15); color: var(--green); }
23
+ .status-badge.warning { background: rgba(234,179,8,0.15); color: var(--yellow); }
24
+
25
+ /* Grid */
26
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; padding: 24px; }
27
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
28
+ .card h2 { font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
29
+ .card .big-number { font-size: 36px; font-weight: 700; line-height: 1; }
30
+ .card .sub { font-size: 13px; color: var(--muted); margin-top: 4px; }
31
+
32
+ /* Stats row */
33
+ .stats-row { display: flex; gap: 12px; margin-top: 12px; }
34
+ .stat { flex: 1; text-align: center; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 8px; }
35
+ .stat .val { font-size: 20px; font-weight: 700; }
36
+ .stat .label { font-size: 11px; color: var(--muted); margin-top: 2px; }
37
+
38
+ /* Table */
39
+ .table-card { grid-column: span 2; }
40
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
41
+ th { text-align: left; padding: 8px 12px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); }
42
+ td { padding: 8px 12px; border-bottom: 1px solid rgba(51,65,85,0.5); }
43
+ tr:hover td { background: rgba(255,255,255,0.02); }
44
+
45
+ /* Badge */
46
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
47
+ .badge.high { background: rgba(239,68,68,0.2); color: var(--red); }
48
+ .badge.medium { background: rgba(234,179,8,0.2); color: var(--yellow); }
49
+ .badge.low { background: rgba(34,197,94,0.2); color: var(--green); }
50
+ .badge.active { background: rgba(59,130,246,0.2); color: var(--accent); }
51
+ .badge.inactive { background: rgba(148,163,184,0.2); color: var(--muted); }
52
+
53
+ /* Chart bar */
54
+ .bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 12px; }
55
+ .bar { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; position: relative; transition: height 0.3s; }
56
+ .bar:hover { opacity: 0.8; }
57
+ .bar .tip { position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--muted); white-space: nowrap; display: none; }
58
+ .bar:hover .tip { display: block; }
59
+ .bar-labels { display: flex; gap: 4px; margin-top: 4px; }
60
+ .bar-labels span { flex: 1; text-align: center; font-size: 10px; color: var(--muted); }
61
+
62
+ /* Sections */
63
+ .full-width { grid-column: 1 / -1; }
64
+ .section-title { padding: 24px 24px 0; font-size: 16px; font-weight: 700; color: var(--muted); }
65
+
66
+ /* Loading */
67
+ .loading { text-align: center; padding: 40px; color: var(--muted); }
68
+ .error { color: var(--red); text-align: center; padding: 20px; }
69
+
70
+ /* Refresh button */
71
+ .refresh-btn { background: var(--accent); color: white; border: none; padding: 6px 14px; border-radius: 6px; font-size: 12px; cursor: pointer; }
72
+ .refresh-btn:hover { opacity: 0.9; }
73
+
74
+ /* Timeline */
75
+ .timeline { list-style: none; }
76
+ .timeline li { padding: 8px 0; border-bottom: 1px solid rgba(51,65,85,0.3); display: flex; gap: 12px; align-items: flex-start; }
77
+ .timeline .time { font-size: 11px; color: var(--muted); min-width: 130px; }
78
+ .timeline .event-type { font-size: 11px; font-weight: 600; min-width: 100px; }
79
+ .timeline .event-summary { font-size: 13px; }
80
+
81
+ @media (max-width: 768px) {
82
+ .grid { grid-template-columns: 1fr; }
83
+ .table-card { grid-column: span 1; }
84
+ }
85
+ </style>
86
+ </head>
87
+ <body>
88
+
89
+ <div class="header">
90
+ <div>
91
+ <h1><span>SpecLock</span> Dashboard</h1>
92
+ <div class="meta">v3.5.0 &mdash; AI Constraint Engine</div>
93
+ </div>
94
+ <div style="display:flex;align-items:center;gap:12px;">
95
+ <span id="health-badge" class="status-badge healthy">Loading...</span>
96
+ <button class="refresh-btn" onclick="loadAll()">Refresh</button>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Overview Cards -->
101
+ <div class="grid">
102
+ <div class="card">
103
+ <h2>Active Locks</h2>
104
+ <div class="big-number" id="lock-count">-</div>
105
+ <div class="sub" id="lock-sub"></div>
106
+ </div>
107
+ <div class="card">
108
+ <h2>Decisions</h2>
109
+ <div class="big-number" id="decision-count">-</div>
110
+ </div>
111
+ <div class="card">
112
+ <h2>Events</h2>
113
+ <div class="big-number" id="event-count">-</div>
114
+ <div class="sub" id="event-sub"></div>
115
+ </div>
116
+ <div class="card">
117
+ <h2>Violations Blocked</h2>
118
+ <div class="big-number" id="violation-count">-</div>
119
+ <div class="stats-row">
120
+ <div class="stat"><div class="val" id="v-blocked" style="color:var(--red)">-</div><div class="label">Blocked</div></div>
121
+ <div class="stat"><div class="val" id="v-advisory" style="color:var(--yellow)">-</div><div class="label">Advisory</div></div>
122
+ <div class="stat"><div class="val" id="v-overrides" style="color:var(--purple)">-</div><div class="label">Overrides</div></div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Enforcement & Security -->
128
+ <div class="section-title">Enforcement & Security</div>
129
+ <div class="grid">
130
+ <div class="card">
131
+ <h2>Enforcement Mode</h2>
132
+ <div class="big-number" id="enforce-mode" style="text-transform:uppercase">-</div>
133
+ <div class="sub" id="enforce-threshold"></div>
134
+ </div>
135
+ <div class="card">
136
+ <h2>Authentication</h2>
137
+ <div class="big-number" id="auth-status">-</div>
138
+ <div class="sub" id="auth-keys"></div>
139
+ </div>
140
+ <div class="card">
141
+ <h2>Encryption</h2>
142
+ <div class="big-number" id="encrypt-status">-</div>
143
+ <div class="sub" id="encrypt-algo"></div>
144
+ </div>
145
+ <div class="card">
146
+ <h2>Audit Chain</h2>
147
+ <div class="big-number" id="audit-status">-</div>
148
+ <div class="sub" id="audit-events"></div>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Active Locks Table -->
153
+ <div class="section-title">Active Locks</div>
154
+ <div class="grid">
155
+ <div class="card table-card">
156
+ <table>
157
+ <thead><tr><th>ID</th><th>Constraint</th><th>Source</th><th>Tags</th><th>Created</th></tr></thead>
158
+ <tbody id="locks-table"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
159
+ </table>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Recent Events Timeline -->
164
+ <div class="section-title">Recent Events</div>
165
+ <div class="grid">
166
+ <div class="card full-width">
167
+ <ul class="timeline" id="events-timeline">
168
+ <li class="loading">Loading...</li>
169
+ </ul>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Sessions -->
174
+ <div class="section-title">Session History</div>
175
+ <div class="grid">
176
+ <div class="card table-card">
177
+ <table>
178
+ <thead><tr><th>Tool</th><th>Started</th><th>Duration</th><th>Events</th><th>Summary</th></tr></thead>
179
+ <tbody id="sessions-table"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
180
+ </table>
181
+ </div>
182
+ </div>
183
+
184
+ <div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
185
+ SpecLock v3.5.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
186
+ </div>
187
+
188
+ <script>
189
+ const API_BASE = window.location.origin;
190
+
191
+ async function apiFetch(path) {
192
+ try {
193
+ const res = await fetch(`${API_BASE}${path}`);
194
+ if (!res.ok) throw new Error(`${res.status}`);
195
+ return await res.json();
196
+ } catch (e) {
197
+ console.error(`API error ${path}:`, e);
198
+ return null;
199
+ }
200
+ }
201
+
202
+ async function loadAll() {
203
+ loadHealth();
204
+ loadContext();
205
+ loadEvents();
206
+ }
207
+
208
+ async function loadHealth() {
209
+ const h = await apiFetch('/health');
210
+ const badge = document.getElementById('health-badge');
211
+ if (h) {
212
+ badge.textContent = h.status === 'healthy' ? 'Healthy' : 'Warning';
213
+ badge.className = `status-badge ${h.status === 'healthy' ? 'healthy' : 'warning'}`;
214
+ document.getElementById('audit-status').textContent = h.auditChain === 'valid' ? 'Valid' : h.auditChain;
215
+ document.getElementById('audit-status').style.color = h.auditChain === 'valid' ? 'var(--green)' : 'var(--red)';
216
+ document.getElementById('auth-status').textContent = h.authEnabled ? 'Enabled' : 'Disabled';
217
+ document.getElementById('auth-status').style.color = h.authEnabled ? 'var(--green)' : 'var(--muted)';
218
+ } else {
219
+ badge.textContent = 'Error';
220
+ badge.className = 'status-badge warning';
221
+ }
222
+ }
223
+
224
+ async function loadContext() {
225
+ // Use a direct brain fetch via the dashboard API
226
+ const data = await apiFetch('/dashboard/api/brain');
227
+ if (!data) return;
228
+
229
+ const brain = data;
230
+ const activeLocks = (brain.specLock?.items || []).filter(l => l.active !== false);
231
+
232
+ // Overview
233
+ document.getElementById('lock-count').textContent = activeLocks.length;
234
+ document.getElementById('lock-sub').textContent = `${brain.specLock?.items?.length || 0} total (${activeLocks.length} active)`;
235
+ document.getElementById('decision-count').textContent = brain.decisions?.length || 0;
236
+ document.getElementById('event-count').textContent = brain.events?.count || 0;
237
+ document.getElementById('event-sub').textContent = `Last: ${brain.events?.lastEventId || 'none'}`;
238
+
239
+ // Violations
240
+ const violations = brain.state?.violations || [];
241
+ document.getElementById('violation-count').textContent = violations.length;
242
+ document.getElementById('v-blocked').textContent = violations.filter(v => v.blocked).length;
243
+ document.getElementById('v-advisory').textContent = violations.filter(v => !v.blocked).length;
244
+
245
+ // Overrides count
246
+ let overrideCount = 0;
247
+ for (const lock of (brain.specLock?.items || [])) {
248
+ overrideCount += (lock.overrides || []).length;
249
+ }
250
+ document.getElementById('v-overrides').textContent = overrideCount;
251
+
252
+ // Enforcement
253
+ const enforcement = brain.enforcement || { mode: 'advisory', blockThreshold: 70 };
254
+ document.getElementById('enforce-mode').textContent = enforcement.mode;
255
+ document.getElementById('enforce-mode').style.color = enforcement.mode === 'hard' ? 'var(--red)' : 'var(--yellow)';
256
+ document.getElementById('enforce-threshold').textContent = `Block threshold: ${enforcement.blockThreshold}%`;
257
+
258
+ // Encryption
259
+ document.getElementById('encrypt-status').textContent = data._encryption ? 'Enabled' : 'Disabled';
260
+ document.getElementById('encrypt-status').style.color = data._encryption ? 'var(--green)' : 'var(--muted)';
261
+ document.getElementById('encrypt-algo').textContent = data._encryption ? 'AES-256-GCM' : 'Set SPECLOCK_ENCRYPTION_KEY to enable';
262
+
263
+ // Auth keys
264
+ document.getElementById('auth-keys').textContent = data._authKeys ? `${data._authKeys} active key(s)` : '';
265
+
266
+ // Locks table
267
+ const locksBody = document.getElementById('locks-table');
268
+ if (activeLocks.length === 0) {
269
+ locksBody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">No active locks</td></tr>';
270
+ } else {
271
+ locksBody.innerHTML = activeLocks.map(l => `
272
+ <tr>
273
+ <td><code>${l.id}</code></td>
274
+ <td>${escHtml(l.text)}</td>
275
+ <td><span class="badge active">${l.source || 'agent'}</span></td>
276
+ <td>${(l.tags || []).map(t => `<span class="badge low">${t}</span>`).join(' ') || '-'}</td>
277
+ <td>${(l.createdAt || '').substring(0, 16)}</td>
278
+ </tr>
279
+ `).join('');
280
+ }
281
+
282
+ // Sessions table
283
+ const sessions = brain.sessions?.history || [];
284
+ const sessBody = document.getElementById('sessions-table');
285
+ if (sessions.length === 0) {
286
+ sessBody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">No sessions yet</td></tr>';
287
+ } else {
288
+ sessBody.innerHTML = sessions.slice(0, 20).map(s => {
289
+ const dur = s.startedAt && s.endedAt ? Math.round((new Date(s.endedAt) - new Date(s.startedAt)) / 60000) + ' min' : '-';
290
+ return `
291
+ <tr>
292
+ <td><span class="badge active">${s.toolUsed || 'unknown'}</span></td>
293
+ <td>${(s.startedAt || '').substring(0, 16)}</td>
294
+ <td>${dur}</td>
295
+ <td>${s.eventsInSession || 0}</td>
296
+ <td>${escHtml((s.summary || '').substring(0, 80))}</td>
297
+ </tr>
298
+ `;
299
+ }).join('');
300
+ }
301
+ }
302
+
303
+ async function loadEvents() {
304
+ const data = await apiFetch('/dashboard/api/events');
305
+ const timeline = document.getElementById('events-timeline');
306
+ if (!data || !data.events || data.events.length === 0) {
307
+ timeline.innerHTML = '<li style="color:var(--muted)">No events yet</li>';
308
+ return;
309
+ }
310
+
311
+ timeline.innerHTML = data.events.slice(0, 30).map(e => `
312
+ <li>
313
+ <span class="time">${(e.at || '').substring(0, 19)}</span>
314
+ <span class="event-type badge ${getEventColor(e.type)}">${e.type}</span>
315
+ <span class="event-summary">${escHtml(e.summary || e.eventId || '')}</span>
316
+ </li>
317
+ `).join('');
318
+ }
319
+
320
+ function getEventColor(type) {
321
+ if (type?.includes('lock') || type?.includes('violation')) return 'high';
322
+ if (type?.includes('session') || type?.includes('decision')) return 'medium';
323
+ return 'low';
324
+ }
325
+
326
+ function escHtml(s) {
327
+ const d = document.createElement('div');
328
+ d.textContent = s || '';
329
+ return d.innerHTML;
330
+ }
331
+
332
+ // Auto-refresh every 30 seconds
333
+ loadAll();
334
+ setInterval(loadAll, 30000);
335
+ </script>
336
+
337
+ </body>
338
+ </html>
@@ -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";
@@ -63,9 +64,34 @@ import {
63
64
  disableAuth,
64
65
  TOOL_PERMISSIONS,
65
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";
66
92
 
67
93
  const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
68
- const VERSION = "3.0.0";
94
+ const VERSION = "3.5.0";
69
95
  const AUTHOR = "Sandeep Roy";
70
96
  const START_TIME = Date.now();
71
97
 
@@ -580,7 +606,136 @@ app.get("/", (req, res) => {
580
606
  });
581
607
  });
582
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
+
583
737
  const PORT = parseInt(process.env.PORT || "3000", 10);
584
738
  app.listen(PORT, "0.0.0.0", () => {
585
739
  console.log(`SpecLock MCP HTTP Server v${VERSION} running on port ${PORT} — Developed by ${AUTHOR}`);
740
+ console.log(` Dashboard: http://localhost:${PORT}/dashboard`);
586
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 {
@@ -90,7 +100,7 @@ const PROJECT_ROOT =
90
100
  args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
91
101
 
92
102
  // --- MCP Server ---
93
- const VERSION = "3.0.0";
103
+ const VERSION = "3.5.0";
94
104
  const AUTHOR = "Sandeep Roy";
95
105
 
96
106
  const server = new McpServer(
@@ -1160,6 +1170,144 @@ server.tool(
1160
1170
  }
1161
1171
  );
1162
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
+
1163
1311
  // --- Smithery sandbox export ---
1164
1312
  export default function createSandboxServer() {
1165
1313
  return server;