speclock 1.7.0 → 2.1.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,291 @@
1
+ /**
2
+ * SpecLock Compliance Export Engine
3
+ * Generates audit-ready reports for SOC 2, HIPAA, and CSV formats.
4
+ * Designed for enterprise compliance teams and auditors.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import { readBrain, readEvents } from "./storage.js";
10
+ import { verifyAuditChain } from "./audit.js";
11
+
12
+ const VERSION = "2.1.0";
13
+
14
+ // PHI-related keywords for HIPAA filtering
15
+ const PHI_KEYWORDS = [
16
+ "patient", "phi", "health", "medical", "hipaa", "ehr", "emr",
17
+ "diagnosis", "treatment", "prescription", "clinical", "healthcare",
18
+ "protected health", "health record", "medical record", "patient data",
19
+ "health information", "insurance", "claims", "billing",
20
+ ];
21
+
22
+ // Security-related event types
23
+ const SECURITY_EVENT_TYPES = [
24
+ "lock_added", "lock_removed", "decision_added",
25
+ "goal_updated", "init", "session_started", "session_ended",
26
+ "checkpoint_created", "revert_detected",
27
+ ];
28
+
29
+ /**
30
+ * Check if text matches any PHI keywords (case-insensitive).
31
+ */
32
+ function isPHIRelated(text) {
33
+ if (!text) return false;
34
+ const lower = text.toLowerCase();
35
+ return PHI_KEYWORDS.some((kw) => lower.includes(kw));
36
+ }
37
+
38
+ /**
39
+ * Generate SOC 2 Type II compliance report.
40
+ * Covers: constraint changes, access events, change management, audit integrity.
41
+ */
42
+ export function exportSOC2(root) {
43
+ const brain = readBrain(root);
44
+ if (!brain) {
45
+ return { error: "SpecLock not initialized. Run speclock init first." };
46
+ }
47
+
48
+ const events = readEvents(root, { limit: 10000 });
49
+ const auditResult = verifyAuditChain(root);
50
+ const activeLocks = (brain.specLock?.items || []).filter((l) => l.active);
51
+ const allLocks = brain.specLock?.items || [];
52
+
53
+ // Group events by type for analysis
54
+ const eventsByType = {};
55
+ for (const e of events) {
56
+ if (!eventsByType[e.type]) eventsByType[e.type] = [];
57
+ eventsByType[e.type].push(e);
58
+ }
59
+
60
+ // Calculate constraint change history
61
+ const constraintChanges = events
62
+ .filter((e) => ["lock_added", "lock_removed"].includes(e.type))
63
+ .map((e) => ({
64
+ timestamp: e.at || e.ts,
65
+ action: e.type === "lock_added" ? "ADDED" : "REMOVED",
66
+ lockId: e.lockId || e.summary?.match(/\[([^\]]+)\]/)?.[1] || "unknown",
67
+ text: e.lockText || e.summary || "",
68
+ source: e.source || "unknown",
69
+ eventId: e.eventId,
70
+ hash: e.hash || null,
71
+ }));
72
+
73
+ // Session history (access log)
74
+ const sessions = (brain.sessions?.history || []).map((s) => ({
75
+ tool: s.tool || "unknown",
76
+ startedAt: s.startedAt,
77
+ endedAt: s.endedAt || null,
78
+ summary: s.summary || "",
79
+ }));
80
+
81
+ // Decision audit trail
82
+ const decisions = (brain.decisions || []).map((d) => ({
83
+ id: d.id,
84
+ text: d.text,
85
+ createdAt: d.createdAt,
86
+ source: d.source || "unknown",
87
+ tags: d.tags || [],
88
+ }));
89
+
90
+ return {
91
+ report: "SOC 2 Type II — SpecLock Compliance Export",
92
+ version: VERSION,
93
+ generatedAt: new Date().toISOString(),
94
+ project: {
95
+ name: brain.project?.name || "unknown",
96
+ id: brain.project?.id || "unknown",
97
+ createdAt: brain.project?.createdAt,
98
+ goal: brain.goal?.text || "",
99
+ },
100
+ auditChainIntegrity: {
101
+ valid: auditResult.valid,
102
+ totalEvents: auditResult.totalEvents,
103
+ hashedEvents: auditResult.hashedEvents,
104
+ unhashedEvents: auditResult.unhashedEvents,
105
+ brokenAt: auditResult.brokenAt,
106
+ verifiedAt: auditResult.verifiedAt,
107
+ },
108
+ constraintManagement: {
109
+ activeConstraints: activeLocks.length,
110
+ totalConstraints: allLocks.length,
111
+ removedConstraints: allLocks.filter((l) => !l.active).length,
112
+ changeHistory: constraintChanges,
113
+ },
114
+ accessLog: {
115
+ totalSessions: sessions.length,
116
+ sessions,
117
+ },
118
+ decisionAuditTrail: {
119
+ totalDecisions: decisions.length,
120
+ decisions,
121
+ },
122
+ changeManagement: {
123
+ totalEvents: events.length,
124
+ eventBreakdown: Object.fromEntries(
125
+ Object.entries(eventsByType).map(([type, evts]) => [type, evts.length])
126
+ ),
127
+ recentChanges: (brain.state?.recentChanges || []).slice(0, 20),
128
+ reverts: brain.state?.reverts || [],
129
+ },
130
+ violations: {
131
+ total: (brain.state?.violations || []).length,
132
+ items: (brain.state?.violations || []).slice(0, 50),
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Generate HIPAA compliance report.
139
+ * Filtered for PHI-related constraints, access events, and encryption status.
140
+ */
141
+ export function exportHIPAA(root) {
142
+ const brain = readBrain(root);
143
+ if (!brain) {
144
+ return { error: "SpecLock not initialized. Run speclock init first." };
145
+ }
146
+
147
+ const events = readEvents(root, { limit: 10000 });
148
+ const auditResult = verifyAuditChain(root);
149
+ const allLocks = brain.specLock?.items || [];
150
+
151
+ // Filter PHI-related locks
152
+ const phiLocks = allLocks.filter((l) => isPHIRelated(l.text));
153
+ const activePhiLocks = phiLocks.filter((l) => l.active);
154
+
155
+ // Filter PHI-related events
156
+ const phiEvents = events.filter(
157
+ (e) => isPHIRelated(e.summary || "") || isPHIRelated(e.lockText || "")
158
+ );
159
+
160
+ // PHI-related decisions
161
+ const phiDecisions = (brain.decisions || []).filter((d) =>
162
+ isPHIRelated(d.text)
163
+ );
164
+
165
+ // PHI-related violations
166
+ const phiViolations = (brain.state?.violations || []).filter(
167
+ (v) => isPHIRelated(v.lockText || "") || isPHIRelated(v.action || "")
168
+ );
169
+
170
+ // Check encryption status
171
+ const encryptionEnabled = !!process.env.SPECLOCK_ENCRYPTION_KEY;
172
+
173
+ // Access controls
174
+ const hasAuth = !!process.env.SPECLOCK_API_KEY;
175
+ const hasAuditChain = auditResult.hashedEvents > 0;
176
+
177
+ return {
178
+ report: "HIPAA Compliance — SpecLock PHI Protection Report",
179
+ version: VERSION,
180
+ generatedAt: new Date().toISOString(),
181
+ project: {
182
+ name: brain.project?.name || "unknown",
183
+ id: brain.project?.id || "unknown",
184
+ },
185
+ safeguards: {
186
+ technicalSafeguards: {
187
+ auditControls: {
188
+ enabled: hasAuditChain,
189
+ chainValid: auditResult.valid,
190
+ totalAuditedEvents: auditResult.hashedEvents,
191
+ status: hasAuditChain
192
+ ? auditResult.valid
193
+ ? "COMPLIANT"
194
+ : "NON-COMPLIANT — audit chain broken"
195
+ : "PARTIAL — enable HMAC audit chain for full compliance",
196
+ },
197
+ accessControl: {
198
+ authEnabled: hasAuth,
199
+ status: hasAuth
200
+ ? "COMPLIANT"
201
+ : "NON-COMPLIANT — no API key authentication",
202
+ },
203
+ encryption: {
204
+ atRest: encryptionEnabled,
205
+ algorithm: encryptionEnabled ? "AES-256-GCM" : "none",
206
+ status: encryptionEnabled
207
+ ? "COMPLIANT"
208
+ : "NON-COMPLIANT — enable SPECLOCK_ENCRYPTION_KEY",
209
+ },
210
+ },
211
+ administrativeSafeguards: {
212
+ constraintEnforcement: {
213
+ totalPhiConstraints: phiLocks.length,
214
+ activePhiConstraints: activePhiLocks.length,
215
+ constraints: activePhiLocks.map((l) => ({
216
+ id: l.id,
217
+ text: l.text,
218
+ createdAt: l.createdAt,
219
+ source: l.source,
220
+ })),
221
+ },
222
+ },
223
+ },
224
+ phiProtection: {
225
+ constraints: phiLocks.map((l) => ({
226
+ id: l.id,
227
+ text: l.text,
228
+ active: l.active,
229
+ createdAt: l.createdAt,
230
+ })),
231
+ decisions: phiDecisions.map((d) => ({
232
+ id: d.id,
233
+ text: d.text,
234
+ createdAt: d.createdAt,
235
+ })),
236
+ violations: phiViolations,
237
+ relatedEvents: phiEvents.length,
238
+ },
239
+ auditTrail: {
240
+ integrity: auditResult,
241
+ phiEventCount: phiEvents.length,
242
+ },
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Generate CSV export of all events.
248
+ * Returns a CSV string suitable for auditor spreadsheets.
249
+ */
250
+ export function exportCSV(root) {
251
+ const events = readEvents(root, { limit: 10000 });
252
+
253
+ if (!events.length) {
254
+ return "timestamp,event_id,type,summary,files,hash\n";
255
+ }
256
+
257
+ // Reverse back to chronological order (readEvents returns newest first)
258
+ events.reverse();
259
+
260
+ const header = "timestamp,event_id,type,summary,files,hash";
261
+ const rows = events.map((e) => {
262
+ const timestamp = e.at || e.ts || "";
263
+ const eventId = e.eventId || "";
264
+ const type = e.type || "";
265
+ const summary = (e.summary || "").replace(/"/g, '""');
266
+ const files = (e.files || []).join("; ");
267
+ const hash = e.hash || "";
268
+ return `"${timestamp}","${eventId}","${type}","${summary}","${files}","${hash}"`;
269
+ });
270
+
271
+ return [header, ...rows].join("\n");
272
+ }
273
+
274
+ /**
275
+ * Main export function — dispatches by format.
276
+ */
277
+ export function exportCompliance(root, format) {
278
+ switch (format) {
279
+ case "soc2":
280
+ return { format: "soc2", data: exportSOC2(root) };
281
+ case "hipaa":
282
+ return { format: "hipaa", data: exportHIPAA(root) };
283
+ case "csv":
284
+ return { format: "csv", data: exportCSV(root) };
285
+ default:
286
+ return {
287
+ error: `Unknown format: ${format}. Supported: soc2, hipaa, csv`,
288
+ supportedFormats: ["soc2", "hipaa", "csv"],
289
+ };
290
+ }
291
+ }
@@ -17,6 +17,11 @@ import {
17
17
  } from "./storage.js";
18
18
  import { hasGit, getHead, getDefaultBranch, captureDiff, getStagedFiles } from "./git.js";
19
19
  import { getTemplateNames, getTemplate } from "./templates.js";
20
+ import { analyzeConflict } from "./semantics.js";
21
+ import { ensureAuditKeyGitignored } from "./audit.js";
22
+ import { verifyAuditChain } from "./audit.js";
23
+ import { exportCompliance } from "./compliance.js";
24
+ import { checkFeature, checkLimits, getLicenseInfo } from "./license.js";
20
25
 
21
26
  // --- Internal helpers ---
22
27
 
@@ -40,6 +45,7 @@ function writePatch(root, eventId, content) {
40
45
 
41
46
  export function ensureInit(root) {
42
47
  ensureSpeclockDirs(root);
48
+ try { ensureAuditKeyGitignored(root); } catch { /* non-critical */ }
43
49
  let brain = readBrain(root);
44
50
  if (!brain) {
45
51
  const gitExists = hasGit(root);
@@ -251,7 +257,8 @@ export function handleFileEvent(root, brain, type, filePath) {
251
257
  recordEvent(root, brain, event);
252
258
  }
253
259
 
254
- // --- Synonym groups for semantic matching ---
260
+ // --- Legacy synonym groups (deprecated — kept for backward compatibility) ---
261
+ // @deprecated Use analyzeConflict() from semantics.js instead
255
262
  const SYNONYM_GROUPS = [
256
263
  ["remove", "delete", "drop", "eliminate", "destroy", "kill", "purge", "wipe"],
257
264
  ["add", "create", "introduce", "insert", "new"],
@@ -270,12 +277,13 @@ const SYNONYM_GROUPS = [
270
277
  ["enable", "activate", "turn-on", "switch-on"],
271
278
  ];
272
279
 
273
- // Negation words that invert meaning
280
+ // @deprecated
274
281
  const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
275
282
 
276
- // Destructive action words
283
+ // @deprecated
277
284
  const DESTRUCTIVE_WORDS = ["remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "break", "disable", "revert", "rollback", "undo"];
278
285
 
286
+ // @deprecated — use analyzeConflict() from semantics.js
279
287
  function expandWithSynonyms(words) {
280
288
  const expanded = new Set(words);
281
289
  for (const word of words) {
@@ -288,17 +296,20 @@ function expandWithSynonyms(words) {
288
296
  return [...expanded];
289
297
  }
290
298
 
299
+ // @deprecated
291
300
  function hasNegation(text) {
292
301
  const lower = text.toLowerCase();
293
302
  return NEGATION_WORDS.some((neg) => lower.includes(neg));
294
303
  }
295
304
 
305
+ // @deprecated
296
306
  function isDestructiveAction(text) {
297
307
  const lower = text.toLowerCase();
298
308
  return DESTRUCTIVE_WORDS.some((w) => lower.includes(w));
299
309
  }
300
310
 
301
311
  // Check if a proposed action conflicts with any active SpecLock
312
+ // v2: Uses the semantic analysis engine from semantics.js
302
313
  export function checkConflict(root, proposedAction) {
303
314
  const brain = ensureInit(root);
304
315
  const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
@@ -310,61 +321,18 @@ export function checkConflict(root, proposedAction) {
310
321
  };
311
322
  }
312
323
 
313
- const actionLower = proposedAction.toLowerCase();
314
- const actionWords = actionLower.split(/\s+/).filter((w) => w.length > 2);
315
- const actionExpanded = expandWithSynonyms(actionWords);
316
- const actionIsDestructive = isDestructiveAction(actionLower);
317
-
318
324
  const conflicting = [];
319
325
  for (const lock of activeLocks) {
320
- const lockLower = lock.text.toLowerCase();
321
- const lockWords = lockLower.split(/\s+/).filter((w) => w.length > 2);
322
- const lockExpanded = expandWithSynonyms(lockWords);
323
-
324
- // Direct keyword overlap
325
- const directOverlap = actionWords.filter((w) => lockWords.includes(w));
326
-
327
- // Synonym-expanded overlap
328
- const synonymOverlap = actionExpanded.filter((w) => lockExpanded.includes(w));
329
- const uniqueSynonymMatches = synonymOverlap.filter((w) => !directOverlap.includes(w));
330
-
331
- // Negation analysis: lock says "No X" and action does X
332
- const lockHasNegation = hasNegation(lockLower);
333
- const actionHasNegation = hasNegation(actionLower);
334
- const negationConflict = lockHasNegation && !actionHasNegation && synonymOverlap.length > 0;
335
-
336
- // Calculate confidence score
337
- let confidence = 0;
338
- let reasons = [];
339
-
340
- if (directOverlap.length > 0) {
341
- confidence += directOverlap.length * 30;
342
- reasons.push(`direct keyword match: ${directOverlap.join(", ")}`);
343
- }
344
- if (uniqueSynonymMatches.length > 0) {
345
- confidence += uniqueSynonymMatches.length * 15;
346
- reasons.push(`synonym match: ${uniqueSynonymMatches.join(", ")}`);
347
- }
348
- if (negationConflict) {
349
- confidence += 40;
350
- reasons.push("lock prohibits this action (negation detected)");
351
- }
352
- if (actionIsDestructive && synonymOverlap.length > 0) {
353
- confidence += 20;
354
- reasons.push("destructive action against locked constraint");
355
- }
356
-
357
- confidence = Math.min(confidence, 100);
326
+ const result = analyzeConflict(proposedAction, lock.text);
358
327
 
359
- if (confidence >= 15) {
360
- const level = confidence >= 70 ? "HIGH" : confidence >= 40 ? "MEDIUM" : "LOW";
328
+ if (result.isConflict) {
361
329
  conflicting.push({
362
330
  id: lock.id,
363
331
  text: lock.text,
364
- matchedKeywords: [...new Set([...directOverlap, ...uniqueSynonymMatches])],
365
- confidence,
366
- level,
367
- reasons,
332
+ matchedKeywords: [],
333
+ confidence: result.confidence,
334
+ level: result.level,
335
+ reasons: result.reasons,
368
336
  });
369
337
  }
370
338
  }
@@ -373,7 +341,7 @@ export function checkConflict(root, proposedAction) {
373
341
  return {
374
342
  hasConflict: false,
375
343
  conflictingLocks: [],
376
- analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (keyword + synonym + negation analysis). Proceed with caution.`,
344
+ analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
377
345
  };
378
346
  }
379
347
 
@@ -406,6 +374,21 @@ export function checkConflict(root, proposedAction) {
406
374
  return result;
407
375
  }
408
376
 
377
+ // Async version — uses LLM if available, falls back to heuristic
378
+ export async function checkConflictAsync(root, proposedAction) {
379
+ // Try LLM first (if llm-checker is available)
380
+ try {
381
+ const { llmCheckConflict } = await import("./llm-checker.js");
382
+ const llmResult = await llmCheckConflict(root, proposedAction);
383
+ if (llmResult) return llmResult;
384
+ } catch (_) {
385
+ // LLM checker not available or failed — fall through to heuristic
386
+ }
387
+
388
+ // Fallback to heuristic
389
+ return checkConflict(root, proposedAction);
390
+ }
391
+
409
392
  // --- Auto-lock suggestions ---
410
393
  export function suggestLocks(root) {
411
394
  const brain = ensureInit(root);
@@ -478,7 +461,7 @@ export function suggestLocks(root) {
478
461
  return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
479
462
  }
480
463
 
481
- // --- Drift detection ---
464
+ // --- Drift detection (v2: uses semantic engine) ---
482
465
  export function detectDrift(root) {
483
466
  const brain = ensureInit(root);
484
467
  const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
@@ -488,29 +471,20 @@ export function detectDrift(root) {
488
471
 
489
472
  const drifts = [];
490
473
 
491
- // Check recent changes against locks
474
+ // Check recent changes against locks using the semantic engine
492
475
  for (const change of brain.state.recentChanges) {
493
- const changeLower = change.summary.toLowerCase();
494
- const changeWords = changeLower.split(/\s+/).filter((w) => w.length > 2);
495
- const changeExpanded = expandWithSynonyms(changeWords);
496
-
497
476
  for (const lock of activeLocks) {
498
- const lockLower = lock.text.toLowerCase();
499
- const lockWords = lockLower.split(/\s+/).filter((w) => w.length > 2);
500
- const lockExpanded = expandWithSynonyms(lockWords);
501
-
502
- const overlap = changeExpanded.filter((w) => lockExpanded.includes(w));
503
- const lockHasNegation = hasNegation(lockLower);
477
+ const result = analyzeConflict(change.summary, lock.text);
504
478
 
505
- if (overlap.length >= 2 && lockHasNegation) {
479
+ if (result.isConflict) {
506
480
  drifts.push({
507
481
  lockId: lock.id,
508
482
  lockText: lock.text,
509
483
  changeEventId: change.eventId,
510
484
  changeSummary: change.summary,
511
485
  changeAt: change.at,
512
- matchedTerms: overlap,
513
- severity: overlap.length >= 3 ? "high" : "medium",
486
+ matchedTerms: result.reasons,
487
+ severity: result.level === "HIGH" ? "high" : "medium",
514
488
  });
515
489
  }
516
490
  }
@@ -1246,3 +1220,9 @@ export function auditStagedFiles(root) {
1246
1220
  message,
1247
1221
  };
1248
1222
  }
1223
+
1224
+ // --- Enterprise features (v2.1) ---
1225
+
1226
+ export { verifyAuditChain } from "./audit.js";
1227
+ export { exportCompliance } from "./compliance.js";
1228
+ export { checkFeature, checkLimits, getLicenseInfo } from "./license.js";