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.
- package/README.md +76 -4
- package/package.json +10 -3
- package/src/cli/index.js +174 -1
- package/src/core/compliance.js +1 -1
- package/src/core/engine.js +38 -0
- package/src/core/policy.js +719 -0
- package/src/core/sso.js +386 -0
- package/src/core/telemetry.js +281 -0
- package/src/dashboard/index.html +338 -0
- package/src/mcp/http-server.js +156 -1
- package/src/mcp/server.js +149 -1
|
@@ -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 — 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 — Developed by Sandeep Roy — <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>
|
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";
|
|
@@ -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.
|
|
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.
|
|
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;
|