speclock 2.1.1 → 3.0.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,341 @@
1
+ /**
2
+ * SpecLock API Key Authentication
3
+ * Provides API key generation, validation, rotation, and revocation.
4
+ * Keys are SHA-256 hashed before storage — raw keys never stored.
5
+ *
6
+ * Storage: .speclock/auth.json (gitignored)
7
+ * Key format: sl_key_<random hex>
8
+ *
9
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
10
+ */
11
+
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import crypto from "crypto";
15
+
16
+ const AUTH_FILE = "auth.json";
17
+ const KEY_PREFIX = "sl_key_";
18
+
19
+ // --- RBAC Role Definitions ---
20
+
21
+ export const ROLES = {
22
+ viewer: {
23
+ name: "viewer",
24
+ description: "Read-only access to context, events, and status",
25
+ permissions: ["read"],
26
+ },
27
+ developer: {
28
+ name: "developer",
29
+ description: "Read access + can override locks with reason",
30
+ permissions: ["read", "override"],
31
+ },
32
+ architect: {
33
+ name: "architect",
34
+ description: "Read + write locks, decisions, goals, notes",
35
+ permissions: ["read", "write", "override"],
36
+ },
37
+ admin: {
38
+ name: "admin",
39
+ description: "Full access including auth management and enforcement config",
40
+ permissions: ["read", "write", "override", "admin"],
41
+ },
42
+ };
43
+
44
+ // Tool → required permission mapping
45
+ export const TOOL_PERMISSIONS = {
46
+ // Read-only tools
47
+ speclock_init: "read",
48
+ speclock_get_context: "read",
49
+ speclock_get_changes: "read",
50
+ speclock_get_events: "read",
51
+ speclock_session_briefing: "read",
52
+ speclock_repo_status: "read",
53
+ speclock_suggest_locks: "read",
54
+ speclock_detect_drift: "read",
55
+ speclock_health: "read",
56
+ speclock_report: "read",
57
+ speclock_verify_audit: "read",
58
+ speclock_export_compliance: "read",
59
+ speclock_override_history: "read",
60
+ speclock_semantic_audit: "read",
61
+
62
+ // Write tools
63
+ speclock_set_goal: "write",
64
+ speclock_add_lock: "write",
65
+ speclock_remove_lock: "write",
66
+ speclock_add_decision: "write",
67
+ speclock_add_note: "write",
68
+ speclock_set_deploy_facts: "write",
69
+ speclock_log_change: "write",
70
+ speclock_check_conflict: "read",
71
+ speclock_session_summary: "write",
72
+ speclock_checkpoint: "write",
73
+ speclock_apply_template: "write",
74
+ speclock_audit: "read",
75
+
76
+ // Override tools
77
+ speclock_override_lock: "override",
78
+
79
+ // Admin tools
80
+ speclock_set_enforcement: "admin",
81
+ };
82
+
83
+ // --- Path helpers ---
84
+
85
+ function authPath(root) {
86
+ return path.join(root, ".speclock", AUTH_FILE);
87
+ }
88
+
89
+ // --- Auth store ---
90
+
91
+ function readAuthStore(root) {
92
+ const p = authPath(root);
93
+ if (!fs.existsSync(p)) {
94
+ return { enabled: false, keys: [] };
95
+ }
96
+ try {
97
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
98
+ } catch {
99
+ return { enabled: false, keys: [] };
100
+ }
101
+ }
102
+
103
+ function writeAuthStore(root, store) {
104
+ const p = authPath(root);
105
+ fs.writeFileSync(p, JSON.stringify(store, null, 2));
106
+ }
107
+
108
+ function hashKey(rawKey) {
109
+ return crypto.createHash("sha256").update(rawKey).digest("hex");
110
+ }
111
+
112
+ function generateRawKey() {
113
+ return KEY_PREFIX + crypto.randomBytes(24).toString("hex");
114
+ }
115
+
116
+ // --- Gitignore auth.json ---
117
+
118
+ export function ensureAuthGitignored(root) {
119
+ const giPath = path.join(root, ".speclock", ".gitignore");
120
+ let content = "";
121
+ if (fs.existsSync(giPath)) {
122
+ content = fs.readFileSync(giPath, "utf-8");
123
+ }
124
+ if (!content.includes(AUTH_FILE)) {
125
+ const line = content.endsWith("\n") || content === "" ? AUTH_FILE + "\n" : "\n" + AUTH_FILE + "\n";
126
+ fs.appendFileSync(giPath, line);
127
+ }
128
+ }
129
+
130
+ // --- Public API ---
131
+
132
+ /**
133
+ * Check if auth is enabled for this project.
134
+ */
135
+ export function isAuthEnabled(root) {
136
+ const store = readAuthStore(root);
137
+ return store.enabled === true && store.keys.length > 0;
138
+ }
139
+
140
+ /**
141
+ * Enable authentication for this project.
142
+ */
143
+ export function enableAuth(root) {
144
+ const store = readAuthStore(root);
145
+ store.enabled = true;
146
+ writeAuthStore(root, store);
147
+ ensureAuthGitignored(root);
148
+ return { success: true };
149
+ }
150
+
151
+ /**
152
+ * Disable authentication (all operations allowed).
153
+ */
154
+ export function disableAuth(root) {
155
+ const store = readAuthStore(root);
156
+ store.enabled = false;
157
+ writeAuthStore(root, store);
158
+ return { success: true };
159
+ }
160
+
161
+ /**
162
+ * Create a new API key with a role and optional name.
163
+ * Returns the raw key (only shown once).
164
+ */
165
+ export function createApiKey(root, role, name = "") {
166
+ if (!ROLES[role]) {
167
+ return { success: false, error: `Invalid role: "${role}". Valid roles: ${Object.keys(ROLES).join(", ")}` };
168
+ }
169
+
170
+ const store = readAuthStore(root);
171
+ const rawKey = generateRawKey();
172
+ const keyHash = hashKey(rawKey);
173
+ const keyId = "key_" + crypto.randomBytes(4).toString("hex");
174
+
175
+ store.keys.push({
176
+ id: keyId,
177
+ name: name || `${role}-${keyId}`,
178
+ hash: keyHash,
179
+ role,
180
+ createdAt: new Date().toISOString(),
181
+ lastUsed: null,
182
+ active: true,
183
+ });
184
+
185
+ if (!store.enabled) {
186
+ store.enabled = true;
187
+ }
188
+
189
+ writeAuthStore(root, store);
190
+ ensureAuthGitignored(root);
191
+
192
+ return {
193
+ success: true,
194
+ keyId,
195
+ rawKey,
196
+ role,
197
+ name: name || `${role}-${keyId}`,
198
+ message: "Save this key — it cannot be retrieved later.",
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Validate an API key. Returns the key record if valid.
204
+ */
205
+ export function validateApiKey(root, rawKey) {
206
+ const store = readAuthStore(root);
207
+
208
+ if (!store.enabled) {
209
+ // Auth not enabled — allow everything (backward compatible)
210
+ return { valid: true, role: "admin", authEnabled: false };
211
+ }
212
+
213
+ if (!rawKey) {
214
+ return { valid: false, error: "API key required. Auth is enabled for this project." };
215
+ }
216
+
217
+ const keyHash = hashKey(rawKey);
218
+ const keyRecord = store.keys.find(k => k.hash === keyHash && k.active);
219
+
220
+ if (!keyRecord) {
221
+ return { valid: false, error: "Invalid or revoked API key." };
222
+ }
223
+
224
+ // Update last used
225
+ keyRecord.lastUsed = new Date().toISOString();
226
+ writeAuthStore(root, store);
227
+
228
+ return {
229
+ valid: true,
230
+ keyId: keyRecord.id,
231
+ role: keyRecord.role,
232
+ name: keyRecord.name,
233
+ authEnabled: true,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Check if a role has permission for a specific tool/action.
239
+ */
240
+ export function checkPermission(role, toolName) {
241
+ const roleConfig = ROLES[role];
242
+ if (!roleConfig) return false;
243
+
244
+ // Admin has all permissions
245
+ if (role === "admin") return true;
246
+
247
+ const requiredPermission = TOOL_PERMISSIONS[toolName];
248
+ if (!requiredPermission) {
249
+ // Unknown tool — default to admin-only
250
+ return role === "admin";
251
+ }
252
+
253
+ return roleConfig.permissions.includes(requiredPermission);
254
+ }
255
+
256
+ /**
257
+ * Rotate an API key — revoke old, create new with same role and name.
258
+ */
259
+ export function rotateApiKey(root, keyId) {
260
+ const store = readAuthStore(root);
261
+ const keyRecord = store.keys.find(k => k.id === keyId && k.active);
262
+
263
+ if (!keyRecord) {
264
+ return { success: false, error: `Active key not found: ${keyId}` };
265
+ }
266
+
267
+ // Revoke old key
268
+ keyRecord.active = false;
269
+ keyRecord.revokedAt = new Date().toISOString();
270
+ keyRecord.revokeReason = "rotated";
271
+
272
+ // Create new key with same role/name
273
+ const rawKey = generateRawKey();
274
+ const newKeyHash = hashKey(rawKey);
275
+ const newKeyId = "key_" + crypto.randomBytes(4).toString("hex");
276
+
277
+ store.keys.push({
278
+ id: newKeyId,
279
+ name: keyRecord.name,
280
+ hash: newKeyHash,
281
+ role: keyRecord.role,
282
+ createdAt: new Date().toISOString(),
283
+ lastUsed: null,
284
+ active: true,
285
+ rotatedFrom: keyId,
286
+ });
287
+
288
+ writeAuthStore(root, store);
289
+
290
+ return {
291
+ success: true,
292
+ oldKeyId: keyId,
293
+ newKeyId,
294
+ rawKey,
295
+ role: keyRecord.role,
296
+ message: "Key rotated. Save the new key — it cannot be retrieved later.",
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Revoke an API key.
302
+ */
303
+ export function revokeApiKey(root, keyId, reason = "manual") {
304
+ const store = readAuthStore(root);
305
+ const keyRecord = store.keys.find(k => k.id === keyId && k.active);
306
+
307
+ if (!keyRecord) {
308
+ return { success: false, error: `Active key not found: ${keyId}` };
309
+ }
310
+
311
+ keyRecord.active = false;
312
+ keyRecord.revokedAt = new Date().toISOString();
313
+ keyRecord.revokeReason = reason;
314
+ writeAuthStore(root, store);
315
+
316
+ return {
317
+ success: true,
318
+ keyId,
319
+ name: keyRecord.name,
320
+ role: keyRecord.role,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * List all API keys (hashes hidden).
326
+ */
327
+ export function listApiKeys(root) {
328
+ const store = readAuthStore(root);
329
+ return {
330
+ enabled: store.enabled,
331
+ keys: store.keys.map(k => ({
332
+ id: k.id,
333
+ name: k.name,
334
+ role: k.role,
335
+ active: k.active,
336
+ createdAt: k.createdAt,
337
+ lastUsed: k.lastUsed,
338
+ revokedAt: k.revokedAt || null,
339
+ })),
340
+ };
341
+ }
@@ -9,7 +9,7 @@
9
9
  import { readBrain, readEvents } from "./storage.js";
10
10
  import { verifyAuditChain } from "./audit.js";
11
11
 
12
- const VERSION = "2.1.1";
12
+ const VERSION = "3.0.0";
13
13
 
14
14
  // PHI-related keywords for HIPAA filtering
15
15
  const PHI_KEYWORDS = [
@@ -0,0 +1,363 @@
1
+ /**
2
+ * SpecLock Conflict Detection Module
3
+ * Conflict checking, drift detection, lock suggestions, audit.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import {
12
+ nowIso,
13
+ readBrain,
14
+ writeBrain,
15
+ readEvents,
16
+ addViolation,
17
+ } from "./storage.js";
18
+ import { getStagedFiles } from "./git.js";
19
+ import { analyzeConflict } from "./semantics.js";
20
+ import { ensureInit } from "./memory.js";
21
+
22
+ // --- Legacy helpers (kept for pre-commit audit backward compat) ---
23
+
24
+ const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
25
+
26
+ function hasNegation(text) {
27
+ const lower = text.toLowerCase();
28
+ return NEGATION_WORDS.some((neg) => lower.includes(neg));
29
+ }
30
+
31
+ const FILE_KEYWORD_PATTERNS = [
32
+ { keywords: ["auth", "authentication", "login", "signup", "signin", "sign-in", "sign-up"], patterns: ["**/Auth*", "**/auth*", "**/Login*", "**/login*", "**/SignUp*", "**/signup*", "**/SignIn*", "**/signin*", "**/*Auth*", "**/*auth*"] },
33
+ { keywords: ["database", "db", "supabase", "firebase", "mongo", "postgres", "sql", "prisma"], patterns: ["**/supabase*", "**/firebase*", "**/database*", "**/db.*", "**/db/**", "**/prisma/**", "**/*Client*", "**/*client*"] },
34
+ { keywords: ["payment", "pay", "stripe", "billing", "checkout", "subscription"], patterns: ["**/payment*", "**/Payment*", "**/pay*", "**/Pay*", "**/stripe*", "**/Stripe*", "**/billing*", "**/Billing*", "**/checkout*", "**/Checkout*"] },
35
+ { keywords: ["api", "endpoint", "route", "routes"], patterns: ["**/api/**", "**/routes/**", "**/endpoints/**"] },
36
+ { keywords: ["config", "configuration", "settings", "env"], patterns: ["**/config*", "**/Config*", "**/settings*", "**/Settings*"] },
37
+ ];
38
+
39
+ function patternMatchesFile(pattern, filePath) {
40
+ const clean = pattern.replace(/\\/g, "/");
41
+ const fileLower = filePath.toLowerCase();
42
+ const patternLower = clean.toLowerCase();
43
+ const namePattern = patternLower.replace(/^\*\*\//, "");
44
+ if (!namePattern.includes("/")) {
45
+ const fileName = fileLower.split("/").pop();
46
+ const regex = new RegExp("^" + namePattern.replace(/\*/g, ".*") + "$");
47
+ if (regex.test(fileName)) return true;
48
+ const corePattern = namePattern.replace(/\*/g, "");
49
+ if (corePattern.length > 2 && fileName.includes(corePattern)) return true;
50
+ }
51
+ const regex = new RegExp("^" + patternLower.replace(/\*\*\//g, "(.*/)?").replace(/\*/g, "[^/]*") + "$");
52
+ return regex.test(fileLower);
53
+ }
54
+
55
+ const GUARD_TAG = "SPECLOCK-GUARD";
56
+
57
+ // --- Core functions ---
58
+
59
+ export function checkConflict(root, proposedAction) {
60
+ const brain = ensureInit(root);
61
+ const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
62
+ if (activeLocks.length === 0) {
63
+ return {
64
+ hasConflict: false,
65
+ conflictingLocks: [],
66
+ analysis: "No active locks. No constraints to check against.",
67
+ };
68
+ }
69
+
70
+ const conflicting = [];
71
+ for (const lock of activeLocks) {
72
+ const result = analyzeConflict(proposedAction, lock.text);
73
+ if (result.isConflict) {
74
+ conflicting.push({
75
+ id: lock.id,
76
+ text: lock.text,
77
+ matchedKeywords: [],
78
+ confidence: result.confidence,
79
+ level: result.level,
80
+ reasons: result.reasons,
81
+ });
82
+ }
83
+ }
84
+
85
+ if (conflicting.length === 0) {
86
+ return {
87
+ hasConflict: false,
88
+ conflictingLocks: [],
89
+ analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
90
+ };
91
+ }
92
+
93
+ conflicting.sort((a, b) => b.confidence - a.confidence);
94
+
95
+ const details = conflicting
96
+ .map(
97
+ (c) =>
98
+ `- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
99
+ )
100
+ .join("\n");
101
+
102
+ const result = {
103
+ hasConflict: true,
104
+ conflictingLocks: conflicting,
105
+ analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
106
+ };
107
+
108
+ addViolation(brain, {
109
+ at: nowIso(),
110
+ action: proposedAction,
111
+ locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
112
+ topLevel: conflicting[0].level,
113
+ topConfidence: conflicting[0].confidence,
114
+ });
115
+ writeBrain(root, brain);
116
+
117
+ return result;
118
+ }
119
+
120
+ export async function checkConflictAsync(root, proposedAction) {
121
+ try {
122
+ const { llmCheckConflict } = await import("./llm-checker.js");
123
+ const llmResult = await llmCheckConflict(root, proposedAction);
124
+ if (llmResult) return llmResult;
125
+ } catch (_) {
126
+ // LLM checker not available — fall through
127
+ }
128
+ return checkConflict(root, proposedAction);
129
+ }
130
+
131
+ export function suggestLocks(root) {
132
+ const brain = ensureInit(root);
133
+ const suggestions = [];
134
+
135
+ for (const dec of brain.decisions) {
136
+ const lower = dec.text.toLowerCase();
137
+ if (/\b(always|must|only|exclusively|never|required)\b/.test(lower)) {
138
+ suggestions.push({
139
+ text: dec.text,
140
+ source: "decision",
141
+ sourceId: dec.id,
142
+ reason: `Decision contains strong commitment language — consider promoting to a lock`,
143
+ });
144
+ }
145
+ }
146
+
147
+ for (const note of brain.notes) {
148
+ const lower = note.text.toLowerCase();
149
+ if (/\b(never|must not|do not|don't|avoid|prohibit|forbidden)\b/.test(lower)) {
150
+ suggestions.push({
151
+ text: note.text,
152
+ source: "note",
153
+ sourceId: note.id,
154
+ reason: `Note contains prohibitive language — consider promoting to a lock`,
155
+ });
156
+ }
157
+ }
158
+
159
+ const existingLockTexts = brain.specLock.items
160
+ .filter((l) => l.active)
161
+ .map((l) => l.text.toLowerCase());
162
+
163
+ const commonPatterns = [
164
+ { keyword: "api", suggestion: "No breaking changes to public API" },
165
+ { keyword: "database", suggestion: "No destructive database migrations without backup" },
166
+ { keyword: "deploy", suggestion: "All deployments must pass CI checks" },
167
+ { keyword: "security", suggestion: "No secrets or credentials in source code" },
168
+ { keyword: "test", suggestion: "No merging without passing tests" },
169
+ ];
170
+
171
+ const allText = [
172
+ brain.goal.text,
173
+ ...brain.decisions.map((d) => d.text),
174
+ ...brain.notes.map((n) => n.text),
175
+ ].join(" ").toLowerCase();
176
+
177
+ for (const pattern of commonPatterns) {
178
+ if (allText.includes(pattern.keyword)) {
179
+ const alreadyLocked = existingLockTexts.some((t) =>
180
+ t.includes(pattern.keyword)
181
+ );
182
+ if (!alreadyLocked) {
183
+ suggestions.push({
184
+ text: pattern.suggestion,
185
+ source: "pattern",
186
+ sourceId: null,
187
+ reason: `Project mentions "${pattern.keyword}" but has no lock protecting it`,
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
194
+ }
195
+
196
+ export function detectDrift(root) {
197
+ const brain = ensureInit(root);
198
+ const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
199
+ if (activeLocks.length === 0) {
200
+ return { drifts: [], status: "no_locks", message: "No active locks to check against." };
201
+ }
202
+
203
+ const drifts = [];
204
+
205
+ for (const change of brain.state.recentChanges) {
206
+ for (const lock of activeLocks) {
207
+ const result = analyzeConflict(change.summary, lock.text);
208
+ if (result.isConflict) {
209
+ drifts.push({
210
+ lockId: lock.id,
211
+ lockText: lock.text,
212
+ changeEventId: change.eventId,
213
+ changeSummary: change.summary,
214
+ changeAt: change.at,
215
+ matchedTerms: result.reasons,
216
+ severity: result.level === "HIGH" ? "high" : "medium",
217
+ });
218
+ }
219
+ }
220
+ }
221
+
222
+ for (const revert of brain.state.reverts) {
223
+ drifts.push({
224
+ lockId: null,
225
+ lockText: "(git revert detected)",
226
+ changeEventId: revert.eventId,
227
+ changeSummary: `Git ${revert.kind} to ${revert.target.substring(0, 12)}`,
228
+ changeAt: revert.at,
229
+ matchedTerms: ["revert"],
230
+ severity: "high",
231
+ });
232
+ }
233
+
234
+ const status = drifts.length === 0 ? "clean" : "drift_detected";
235
+ const message = drifts.length === 0
236
+ ? `All clear. ${activeLocks.length} lock(s) checked against ${brain.state.recentChanges.length} recent change(s). No drift detected.`
237
+ : `WARNING: ${drifts.length} potential drift(s) detected. Review immediately.`;
238
+
239
+ return { drifts, status, message };
240
+ }
241
+
242
+ export function generateReport(root) {
243
+ const brain = ensureInit(root);
244
+ const violations = brain.state.violations || [];
245
+
246
+ if (violations.length === 0) {
247
+ return {
248
+ totalViolations: 0,
249
+ violationsByLock: {},
250
+ mostTestedLocks: [],
251
+ recentViolations: [],
252
+ summary: "No violations recorded yet. SpecLock is watching.",
253
+ };
254
+ }
255
+
256
+ const byLock = {};
257
+ for (const v of violations) {
258
+ for (const lock of v.locks) {
259
+ if (!byLock[lock.text]) {
260
+ byLock[lock.text] = { count: 0, lockId: lock.id, text: lock.text };
261
+ }
262
+ byLock[lock.text].count++;
263
+ }
264
+ }
265
+
266
+ const mostTested = Object.values(byLock).sort((a, b) => b.count - a.count);
267
+ const recent = violations.slice(0, 10).map((v) => ({
268
+ at: v.at,
269
+ action: v.action,
270
+ topLevel: v.topLevel,
271
+ topConfidence: v.topConfidence,
272
+ lockCount: v.locks.length,
273
+ }));
274
+
275
+ const oldest = violations[violations.length - 1];
276
+ const newest = violations[0];
277
+
278
+ return {
279
+ totalViolations: violations.length,
280
+ timeRange: { from: oldest.at, to: newest.at },
281
+ violationsByLock: byLock,
282
+ mostTestedLocks: mostTested.slice(0, 5),
283
+ recentViolations: recent,
284
+ summary: `SpecLock blocked ${violations.length} violation(s). Most tested lock: "${mostTested[0].text}" (${mostTested[0].count} blocks).`,
285
+ };
286
+ }
287
+
288
+ export function auditStagedFiles(root) {
289
+ const brain = ensureInit(root);
290
+ const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
291
+
292
+ if (activeLocks.length === 0) {
293
+ return { passed: true, violations: [], checkedFiles: 0, activeLocks: 0, message: "No active locks. Audit passed." };
294
+ }
295
+
296
+ const stagedFiles = getStagedFiles(root);
297
+ if (stagedFiles.length === 0) {
298
+ return { passed: true, violations: [], checkedFiles: 0, activeLocks: activeLocks.length, message: "No staged files. Audit passed." };
299
+ }
300
+
301
+ const violations = [];
302
+
303
+ for (const file of stagedFiles) {
304
+ const fullPath = path.join(root, file);
305
+ if (fs.existsSync(fullPath)) {
306
+ try {
307
+ const content = fs.readFileSync(fullPath, "utf-8");
308
+ if (content.includes(GUARD_TAG)) {
309
+ violations.push({
310
+ file,
311
+ reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
312
+ lockText: "(file-level guard)",
313
+ severity: "HIGH",
314
+ });
315
+ continue;
316
+ }
317
+ } catch (_) {}
318
+ }
319
+
320
+ const fileLower = file.toLowerCase();
321
+ for (const lock of activeLocks) {
322
+ const lockLower = lock.text.toLowerCase();
323
+ const lockHasNegation = hasNegation(lockLower);
324
+ if (!lockHasNegation) continue;
325
+
326
+ for (const group of FILE_KEYWORD_PATTERNS) {
327
+ const lockMatchesKeyword = group.keywords.some((kw) => lockLower.includes(kw));
328
+ if (!lockMatchesKeyword) continue;
329
+
330
+ const fileMatchesPattern = group.patterns.some((pattern) => patternMatchesFile(pattern, fileLower) || patternMatchesFile(pattern, fileLower.split("/").pop()));
331
+ if (fileMatchesPattern) {
332
+ violations.push({
333
+ file,
334
+ reason: `File matches lock keyword pattern`,
335
+ lockText: lock.text,
336
+ severity: "MEDIUM",
337
+ });
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ const seen = new Set();
345
+ const unique = violations.filter((v) => {
346
+ if (seen.has(v.file)) return false;
347
+ seen.add(v.file);
348
+ return true;
349
+ });
350
+
351
+ const passed = unique.length === 0;
352
+ const message = passed
353
+ ? `Audit passed. ${stagedFiles.length} file(s) checked against ${activeLocks.length} lock(s).`
354
+ : `AUDIT FAILED: ${unique.length} violation(s) in ${stagedFiles.length} staged file(s).`;
355
+
356
+ return {
357
+ passed,
358
+ violations: unique,
359
+ checkedFiles: stagedFiles.length,
360
+ activeLocks: activeLocks.length,
361
+ message,
362
+ };
363
+ }