speclock 3.0.0 → 3.5.1
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 +157 -2
- package/src/mcp/server.js +149 -1
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Policy-as-Code Engine (v3.5)
|
|
3
|
+
* Declarative YAML-based policy rules for enterprise constraint enforcement.
|
|
4
|
+
*
|
|
5
|
+
* Policy files: .speclock/policy.yml
|
|
6
|
+
* Rules match file patterns + action types with enforcement levels.
|
|
7
|
+
* Supports notifications, severity levels, and cross-org import/export.
|
|
8
|
+
*
|
|
9
|
+
* YAML parsing: lightweight built-in parser (no external deps).
|
|
10
|
+
*
|
|
11
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { readBrain, writeBrain, appendEvent, newId, nowIso, bumpEvents } from "./storage.js";
|
|
17
|
+
|
|
18
|
+
// --- Lightweight YAML parser (handles policy.yml subset) ---
|
|
19
|
+
|
|
20
|
+
function parseYaml(text) {
|
|
21
|
+
const lines = text.split("\n");
|
|
22
|
+
const result = {};
|
|
23
|
+
const stack = [{ obj: result, indent: -1 }];
|
|
24
|
+
let currentArray = null;
|
|
25
|
+
let currentArrayKey = null;
|
|
26
|
+
let arrayItemIndent = -1;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const raw = lines[i];
|
|
30
|
+
if (!raw.trim() || raw.trim().startsWith("#")) continue;
|
|
31
|
+
|
|
32
|
+
const indent = raw.search(/\S/);
|
|
33
|
+
const trimmed = raw.trim();
|
|
34
|
+
|
|
35
|
+
// Array item
|
|
36
|
+
if (trimmed.startsWith("- ")) {
|
|
37
|
+
const value = trimmed.slice(2).trim();
|
|
38
|
+
|
|
39
|
+
// Array of objects (- key: value)
|
|
40
|
+
if (value.includes(":")) {
|
|
41
|
+
const colonIdx = value.indexOf(":");
|
|
42
|
+
const k = value.slice(0, colonIdx).trim();
|
|
43
|
+
const v = value.slice(colonIdx + 1).trim();
|
|
44
|
+
|
|
45
|
+
if (currentArray && currentArrayKey) {
|
|
46
|
+
const item = {};
|
|
47
|
+
item[k] = parseValue(v);
|
|
48
|
+
currentArray.push(item);
|
|
49
|
+
arrayItemIndent = indent;
|
|
50
|
+
|
|
51
|
+
// Look ahead for more key-value pairs in this item
|
|
52
|
+
let j = i + 1;
|
|
53
|
+
while (j < lines.length) {
|
|
54
|
+
const nextRaw = lines[j];
|
|
55
|
+
if (!nextRaw.trim() || nextRaw.trim().startsWith("#")) { j++; continue; }
|
|
56
|
+
const nextIndent = nextRaw.search(/\S/);
|
|
57
|
+
const nextTrimmed = nextRaw.trim();
|
|
58
|
+
if (nextIndent <= indent) break;
|
|
59
|
+
if (nextTrimmed.startsWith("- ")) break;
|
|
60
|
+
|
|
61
|
+
if (nextTrimmed.includes(":")) {
|
|
62
|
+
const nc = nextTrimmed.indexOf(":");
|
|
63
|
+
const nk = nextTrimmed.slice(0, nc).trim();
|
|
64
|
+
const nv = nextTrimmed.slice(nc + 1).trim();
|
|
65
|
+
|
|
66
|
+
// Empty value — determine type by peeking at next deeper line
|
|
67
|
+
if (!nv) {
|
|
68
|
+
// Peek at the next non-empty line with deeper indent
|
|
69
|
+
let peekJ = j + 1;
|
|
70
|
+
let peekLine = null;
|
|
71
|
+
while (peekJ < lines.length) {
|
|
72
|
+
const pRaw = lines[peekJ];
|
|
73
|
+
if (pRaw.trim() && !pRaw.trim().startsWith("#")) {
|
|
74
|
+
const pIndent = pRaw.search(/\S/);
|
|
75
|
+
if (pIndent > nextIndent) {
|
|
76
|
+
peekLine = { indent: pIndent, trimmed: pRaw.trim() };
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
peekJ++;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!peekLine) {
|
|
84
|
+
// No deeper content — empty string
|
|
85
|
+
item[nk] = "";
|
|
86
|
+
j++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (peekLine.trimmed.startsWith("- ")) {
|
|
91
|
+
// It's a flat array
|
|
92
|
+
item[nk] = [];
|
|
93
|
+
let k2 = j + 1;
|
|
94
|
+
while (k2 < lines.length) {
|
|
95
|
+
const subRaw = lines[k2];
|
|
96
|
+
if (!subRaw.trim() || subRaw.trim().startsWith("#")) { k2++; continue; }
|
|
97
|
+
const subIndent = subRaw.search(/\S/);
|
|
98
|
+
const subTrimmed = subRaw.trim();
|
|
99
|
+
if (subIndent <= nextIndent) break;
|
|
100
|
+
if (subTrimmed.startsWith("- ")) {
|
|
101
|
+
item[nk].push(parseValue(subTrimmed.slice(2).trim()));
|
|
102
|
+
}
|
|
103
|
+
k2++;
|
|
104
|
+
}
|
|
105
|
+
j = k2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (peekLine.trimmed.includes(":")) {
|
|
110
|
+
// It's a nested object
|
|
111
|
+
item[nk] = {};
|
|
112
|
+
let k2 = j + 1;
|
|
113
|
+
while (k2 < lines.length) {
|
|
114
|
+
const subRaw = lines[k2];
|
|
115
|
+
if (!subRaw.trim() || subRaw.trim().startsWith("#")) { k2++; continue; }
|
|
116
|
+
const subIndent = subRaw.search(/\S/);
|
|
117
|
+
const subTrimmed = subRaw.trim();
|
|
118
|
+
if (subIndent <= nextIndent) break;
|
|
119
|
+
|
|
120
|
+
if (subTrimmed.includes(":")) {
|
|
121
|
+
const sc = subTrimmed.indexOf(":");
|
|
122
|
+
const sk = subTrimmed.slice(0, sc).trim();
|
|
123
|
+
const sv = subTrimmed.slice(sc + 1).trim();
|
|
124
|
+
|
|
125
|
+
if (!sv) {
|
|
126
|
+
// Nested-nested: peek to determine array vs object
|
|
127
|
+
let peek2 = k2 + 1;
|
|
128
|
+
let isNestedArray = false;
|
|
129
|
+
while (peek2 < lines.length) {
|
|
130
|
+
const p2Raw = lines[peek2];
|
|
131
|
+
if (p2Raw.trim() && !p2Raw.trim().startsWith("#")) {
|
|
132
|
+
const p2Indent = p2Raw.search(/\S/);
|
|
133
|
+
if (p2Indent > subIndent && p2Raw.trim().startsWith("- ")) {
|
|
134
|
+
isNestedArray = true;
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
peek2++;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isNestedArray) {
|
|
142
|
+
item[nk][sk] = [];
|
|
143
|
+
let k3 = k2 + 1;
|
|
144
|
+
while (k3 < lines.length) {
|
|
145
|
+
const sub2Raw = lines[k3];
|
|
146
|
+
if (!sub2Raw.trim() || sub2Raw.trim().startsWith("#")) { k3++; continue; }
|
|
147
|
+
const sub2Indent = sub2Raw.search(/\S/);
|
|
148
|
+
const sub2Trimmed = sub2Raw.trim();
|
|
149
|
+
if (sub2Indent <= subIndent) break;
|
|
150
|
+
if (sub2Trimmed.startsWith("- ")) {
|
|
151
|
+
item[nk][sk].push(parseValue(sub2Trimmed.slice(2).trim()));
|
|
152
|
+
}
|
|
153
|
+
k3++;
|
|
154
|
+
}
|
|
155
|
+
k2 = k3;
|
|
156
|
+
continue;
|
|
157
|
+
} else {
|
|
158
|
+
item[nk][sk] = "";
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
item[nk][sk] = parseValue(sv);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
k2++;
|
|
165
|
+
}
|
|
166
|
+
j = k2;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fallback — treat as empty string
|
|
171
|
+
item[nk] = "";
|
|
172
|
+
j++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
item[nk] = parseValue(nv);
|
|
177
|
+
}
|
|
178
|
+
j++;
|
|
179
|
+
}
|
|
180
|
+
i = j - 1;
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Simple array item
|
|
186
|
+
if (currentArray) {
|
|
187
|
+
currentArray.push(parseValue(value));
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Key-value pair
|
|
193
|
+
if (trimmed.includes(":")) {
|
|
194
|
+
const colonIdx = trimmed.indexOf(":");
|
|
195
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
196
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
197
|
+
|
|
198
|
+
// Pop stack to find parent
|
|
199
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
200
|
+
stack.pop();
|
|
201
|
+
}
|
|
202
|
+
const parent = stack[stack.length - 1].obj;
|
|
203
|
+
|
|
204
|
+
if (value === "" || value === undefined) {
|
|
205
|
+
// Could be object or array — peek ahead
|
|
206
|
+
const nextLine = lines[i + 1];
|
|
207
|
+
if (nextLine && nextLine.trim().startsWith("- ")) {
|
|
208
|
+
parent[key] = [];
|
|
209
|
+
currentArray = parent[key];
|
|
210
|
+
currentArrayKey = key;
|
|
211
|
+
arrayItemIndent = -1;
|
|
212
|
+
} else {
|
|
213
|
+
parent[key] = {};
|
|
214
|
+
stack.push({ obj: parent[key], indent });
|
|
215
|
+
currentArray = null;
|
|
216
|
+
currentArrayKey = null;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
parent[key] = parseValue(value);
|
|
220
|
+
currentArray = null;
|
|
221
|
+
currentArrayKey = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseValue(str) {
|
|
230
|
+
if (!str) return "";
|
|
231
|
+
// Remove quotes
|
|
232
|
+
if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
|
|
233
|
+
return str.slice(1, -1);
|
|
234
|
+
}
|
|
235
|
+
// Boolean
|
|
236
|
+
if (str === "true") return true;
|
|
237
|
+
if (str === "false") return false;
|
|
238
|
+
// Number
|
|
239
|
+
if (/^-?\d+(\.\d+)?$/.test(str)) return Number(str);
|
|
240
|
+
// Array shorthand [a, b, c]
|
|
241
|
+
if (str.startsWith("[") && str.endsWith("]")) {
|
|
242
|
+
const inner = str.slice(1, -1).trim();
|
|
243
|
+
if (inner === "") return [];
|
|
244
|
+
return inner.split(",").map(s => parseValue(s.trim()));
|
|
245
|
+
}
|
|
246
|
+
return str;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Serialize to YAML ---
|
|
250
|
+
|
|
251
|
+
function toYaml(obj, indent = 0) {
|
|
252
|
+
const pad = " ".repeat(indent);
|
|
253
|
+
let out = "";
|
|
254
|
+
|
|
255
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
256
|
+
if (Array.isArray(value)) {
|
|
257
|
+
if (value.length === 0) {
|
|
258
|
+
out += `${pad}${key}: []\n`;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
out += `${pad}${key}:\n`;
|
|
262
|
+
for (const item of value) {
|
|
263
|
+
if (typeof item === "object" && item !== null) {
|
|
264
|
+
const entries = Object.entries(item);
|
|
265
|
+
if (entries.length > 0) {
|
|
266
|
+
out += `${pad} - ${entries[0][0]}: ${formatValue(entries[0][1])}\n`;
|
|
267
|
+
for (let i = 1; i < entries.length; i++) {
|
|
268
|
+
const [k, v] = entries[i];
|
|
269
|
+
if (Array.isArray(v)) {
|
|
270
|
+
if (v.length === 0) {
|
|
271
|
+
out += `${pad} ${k}: []\n`;
|
|
272
|
+
} else {
|
|
273
|
+
out += `${pad} ${k}:\n`;
|
|
274
|
+
for (const sv of v) {
|
|
275
|
+
out += `${pad} - ${formatValue(sv)}\n`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} else if (typeof v === "object" && v !== null) {
|
|
279
|
+
out += `${pad} ${k}:\n`;
|
|
280
|
+
out += toYaml(v, indent + 3);
|
|
281
|
+
} else {
|
|
282
|
+
out += `${pad} ${k}: ${formatValue(v)}\n`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
out += `${pad} - ${formatValue(item)}\n`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} else if (typeof value === "object" && value !== null) {
|
|
291
|
+
out += `${pad}${key}:\n`;
|
|
292
|
+
out += toYaml(value, indent + 1);
|
|
293
|
+
} else {
|
|
294
|
+
out += `${pad}${key}: ${formatValue(value)}\n`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function formatValue(v) {
|
|
302
|
+
if (typeof v === "string") {
|
|
303
|
+
// Quote strings that look like numbers but should stay as strings (e.g., version "1.0")
|
|
304
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) {
|
|
305
|
+
return `"${v}"`;
|
|
306
|
+
}
|
|
307
|
+
if (v.includes(":") || v.includes("#") || v.includes("'") || v.includes('"') || v.startsWith("*")) {
|
|
308
|
+
return `"${v.replace(/"/g, '\\"')}"`;
|
|
309
|
+
}
|
|
310
|
+
return v;
|
|
311
|
+
}
|
|
312
|
+
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
313
|
+
return String(v);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- Policy file paths ---
|
|
317
|
+
|
|
318
|
+
function policyPath(root) {
|
|
319
|
+
return path.join(root, ".speclock", "policy.yml");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- Default policy template ---
|
|
323
|
+
|
|
324
|
+
function defaultPolicy() {
|
|
325
|
+
return {
|
|
326
|
+
version: "1.0",
|
|
327
|
+
name: "Default Policy",
|
|
328
|
+
description: "SpecLock policy-as-code rules",
|
|
329
|
+
rules: [],
|
|
330
|
+
notifications: {
|
|
331
|
+
enabled: false,
|
|
332
|
+
channels: [],
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- Policy CRUD ---
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Load policy from .speclock/policy.yml
|
|
341
|
+
*/
|
|
342
|
+
export function loadPolicy(root) {
|
|
343
|
+
const p = policyPath(root);
|
|
344
|
+
if (!fs.existsSync(p)) return null;
|
|
345
|
+
try {
|
|
346
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
347
|
+
return parseYaml(raw);
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Save policy to .speclock/policy.yml
|
|
355
|
+
*/
|
|
356
|
+
export function savePolicy(root, policy) {
|
|
357
|
+
const p = policyPath(root);
|
|
358
|
+
const yaml = `# SpecLock Policy-as-Code\n# Generated at ${nowIso()}\n# Docs: https://github.com/sgroy10/speclock\n\n${toYaml(policy)}`;
|
|
359
|
+
fs.writeFileSync(p, yaml);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Initialize policy with default template
|
|
364
|
+
*/
|
|
365
|
+
export function initPolicy(root) {
|
|
366
|
+
const existing = loadPolicy(root);
|
|
367
|
+
if (existing) {
|
|
368
|
+
return { success: false, error: "Policy already exists. Use loadPolicy to read it." };
|
|
369
|
+
}
|
|
370
|
+
const policy = defaultPolicy();
|
|
371
|
+
savePolicy(root, policy);
|
|
372
|
+
|
|
373
|
+
// Log event
|
|
374
|
+
const brain = readBrain(root);
|
|
375
|
+
if (brain) {
|
|
376
|
+
const eventId = newId("evt");
|
|
377
|
+
appendEvent(root, { eventId, type: "policy_created", at: nowIso(), summary: "Policy-as-code initialized" });
|
|
378
|
+
bumpEvents(brain, eventId);
|
|
379
|
+
writeBrain(root, brain);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { success: true, policy };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Add a policy rule
|
|
387
|
+
*/
|
|
388
|
+
export function addPolicyRule(root, rule) {
|
|
389
|
+
let policy = loadPolicy(root);
|
|
390
|
+
if (!policy) {
|
|
391
|
+
policy = defaultPolicy();
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(policy.rules)) policy.rules = [];
|
|
394
|
+
|
|
395
|
+
// Validate rule
|
|
396
|
+
if (!rule.name) return { success: false, error: "Rule name is required." };
|
|
397
|
+
if (!rule.match) return { success: false, error: "Rule match criteria required (files, actions)." };
|
|
398
|
+
if (!rule.enforce) rule.enforce = "warn";
|
|
399
|
+
|
|
400
|
+
const ruleId = newId("rule");
|
|
401
|
+
const policyRule = {
|
|
402
|
+
id: ruleId,
|
|
403
|
+
name: rule.name,
|
|
404
|
+
description: rule.description || "",
|
|
405
|
+
match: {
|
|
406
|
+
files: rule.match.files || ["**/*"],
|
|
407
|
+
actions: rule.match.actions || ["modify", "delete"],
|
|
408
|
+
},
|
|
409
|
+
enforce: rule.enforce, // block, warn, log
|
|
410
|
+
severity: rule.severity || "medium",
|
|
411
|
+
notify: rule.notify || [],
|
|
412
|
+
active: true,
|
|
413
|
+
createdAt: nowIso(),
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
policy.rules.push(policyRule);
|
|
417
|
+
savePolicy(root, policy);
|
|
418
|
+
|
|
419
|
+
// Log event
|
|
420
|
+
const brain = readBrain(root);
|
|
421
|
+
if (brain) {
|
|
422
|
+
const eventId = newId("evt");
|
|
423
|
+
appendEvent(root, { eventId, type: "policy_rule_added", at: nowIso(), summary: `Policy rule added: ${rule.name}`, ruleId });
|
|
424
|
+
bumpEvents(brain, eventId);
|
|
425
|
+
writeBrain(root, brain);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { success: true, ruleId, rule: policyRule };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Remove a policy rule by ID
|
|
433
|
+
*/
|
|
434
|
+
export function removePolicyRule(root, ruleId) {
|
|
435
|
+
const policy = loadPolicy(root);
|
|
436
|
+
if (!policy || !Array.isArray(policy.rules)) {
|
|
437
|
+
return { success: false, error: "No policy found." };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const idx = policy.rules.findIndex(r => r.id === ruleId);
|
|
441
|
+
if (idx === -1) {
|
|
442
|
+
return { success: false, error: `Rule not found: ${ruleId}` };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const removed = policy.rules.splice(idx, 1)[0];
|
|
446
|
+
savePolicy(root, policy);
|
|
447
|
+
|
|
448
|
+
// Log event
|
|
449
|
+
const brain = readBrain(root);
|
|
450
|
+
if (brain) {
|
|
451
|
+
const eventId = newId("evt");
|
|
452
|
+
appendEvent(root, { eventId, type: "policy_rule_removed", at: nowIso(), summary: `Policy rule removed: ${removed.name}`, ruleId });
|
|
453
|
+
bumpEvents(brain, eventId);
|
|
454
|
+
writeBrain(root, brain);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return { success: true, removed };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* List all policy rules
|
|
462
|
+
*/
|
|
463
|
+
export function listPolicyRules(root) {
|
|
464
|
+
const policy = loadPolicy(root);
|
|
465
|
+
if (!policy || !Array.isArray(policy.rules)) {
|
|
466
|
+
return { rules: [], total: 0, active: 0 };
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
rules: policy.rules,
|
|
470
|
+
total: policy.rules.length,
|
|
471
|
+
active: policy.rules.filter(r => r.active !== false).length,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- Policy Evaluation ---
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Match a file path against glob patterns (simple matcher)
|
|
479
|
+
*/
|
|
480
|
+
function matchesPattern(filePath, pattern) {
|
|
481
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
482
|
+
const patternNorm = pattern.replace(/\\/g, "/");
|
|
483
|
+
|
|
484
|
+
// Exact match
|
|
485
|
+
if (normalized === patternNorm) return true;
|
|
486
|
+
|
|
487
|
+
// Convert glob to regex — use placeholders to avoid interference between conversions
|
|
488
|
+
const regex = patternNorm
|
|
489
|
+
.replace(/\./g, "\\.")
|
|
490
|
+
.replace(/\*\*\//g, "\x00GLOBSTAR\x00")
|
|
491
|
+
.replace(/\*\*/g, "\x00DSTAR\x00")
|
|
492
|
+
.replace(/\*/g, "[^/]*")
|
|
493
|
+
.replace(/\?/g, ".")
|
|
494
|
+
.replace(/\x00GLOBSTAR\x00/g, "(.+/)?")
|
|
495
|
+
.replace(/\x00DSTAR\x00/g, ".*");
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
return new RegExp(`^${regex}$`, "i").test(normalized);
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Evaluate policy rules against a proposed action
|
|
506
|
+
* Returns violations for any matching rules
|
|
507
|
+
*/
|
|
508
|
+
export function evaluatePolicy(root, action) {
|
|
509
|
+
const policy = loadPolicy(root);
|
|
510
|
+
if (!policy || !Array.isArray(policy.rules) || policy.rules.length === 0) {
|
|
511
|
+
return { violations: [], passed: true, rulesChecked: 0 };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const activeRules = policy.rules.filter(r => r.active !== false);
|
|
515
|
+
const violations = [];
|
|
516
|
+
|
|
517
|
+
for (const rule of activeRules) {
|
|
518
|
+
const match = matchesPolicyRule(rule, action);
|
|
519
|
+
if (match.matched) {
|
|
520
|
+
violations.push({
|
|
521
|
+
ruleId: rule.id,
|
|
522
|
+
ruleName: rule.name,
|
|
523
|
+
description: rule.description,
|
|
524
|
+
enforce: rule.enforce,
|
|
525
|
+
severity: rule.severity,
|
|
526
|
+
matchedFiles: match.matchedFiles,
|
|
527
|
+
matchedAction: match.matchedAction,
|
|
528
|
+
notify: rule.notify || [],
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const blocked = violations.some(v => v.enforce === "block");
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
violations,
|
|
537
|
+
passed: violations.length === 0,
|
|
538
|
+
blocked,
|
|
539
|
+
rulesChecked: activeRules.length,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Check if a rule matches an action
|
|
545
|
+
*/
|
|
546
|
+
function matchesPolicyRule(rule, action) {
|
|
547
|
+
const result = { matched: false, matchedFiles: [], matchedAction: null };
|
|
548
|
+
|
|
549
|
+
if (!rule.match) return result;
|
|
550
|
+
|
|
551
|
+
// Check action type match
|
|
552
|
+
const actionTypes = rule.match.actions || ["modify", "delete", "create", "export"];
|
|
553
|
+
const actionType = action.type || "modify";
|
|
554
|
+
const actionMatched = actionTypes.includes(actionType) || actionTypes.includes("*");
|
|
555
|
+
if (!actionMatched) return result;
|
|
556
|
+
|
|
557
|
+
// Check file pattern match
|
|
558
|
+
const filePatterns = rule.match.files || ["**/*"];
|
|
559
|
+
const files = action.files || [];
|
|
560
|
+
|
|
561
|
+
if (files.length === 0) {
|
|
562
|
+
// No specific files — check if action description matches rule semantically
|
|
563
|
+
const actionText = (action.description || action.text || "").toLowerCase();
|
|
564
|
+
const ruleName = (rule.name || "").toLowerCase();
|
|
565
|
+
const ruleDesc = (rule.description || "").toLowerCase();
|
|
566
|
+
|
|
567
|
+
// Simple keyword overlap check
|
|
568
|
+
const ruleWords = `${ruleName} ${ruleDesc}`.split(/\s+/).filter(w => w.length > 3);
|
|
569
|
+
const actionWords = actionText.split(/\s+/).filter(w => w.length > 3);
|
|
570
|
+
const overlap = ruleWords.filter(w => actionWords.some(aw => aw.includes(w) || w.includes(aw)));
|
|
571
|
+
|
|
572
|
+
if (overlap.length > 0) {
|
|
573
|
+
result.matched = true;
|
|
574
|
+
result.matchedAction = actionType;
|
|
575
|
+
}
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Match files against patterns
|
|
580
|
+
for (const file of files) {
|
|
581
|
+
for (const pattern of filePatterns) {
|
|
582
|
+
if (matchesPattern(file, pattern)) {
|
|
583
|
+
result.matchedFiles.push(file);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (result.matchedFiles.length > 0) {
|
|
590
|
+
result.matched = true;
|
|
591
|
+
result.matchedAction = actionType;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// --- Policy Import/Export ---
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Export policy as portable YAML string
|
|
601
|
+
*/
|
|
602
|
+
export function exportPolicy(root) {
|
|
603
|
+
const policy = loadPolicy(root);
|
|
604
|
+
if (!policy) {
|
|
605
|
+
return { success: false, error: "No policy found." };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Strip internal IDs and timestamps for portability
|
|
609
|
+
const portable = {
|
|
610
|
+
version: policy.version || "1.0",
|
|
611
|
+
name: policy.name || "Exported Policy",
|
|
612
|
+
description: policy.description || "",
|
|
613
|
+
rules: (Array.isArray(policy.rules) ? policy.rules : []).map(r => ({
|
|
614
|
+
name: r.name,
|
|
615
|
+
description: r.description || "",
|
|
616
|
+
match: r.match,
|
|
617
|
+
enforce: r.enforce,
|
|
618
|
+
severity: r.severity,
|
|
619
|
+
notify: r.notify || [],
|
|
620
|
+
})),
|
|
621
|
+
notifications: policy.notifications || { enabled: false, channels: [] },
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
return { success: true, yaml: toYaml(portable), policy: portable };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Import policy from YAML string (merges or replaces)
|
|
629
|
+
*/
|
|
630
|
+
export function importPolicy(root, yamlString, mode = "merge") {
|
|
631
|
+
let imported;
|
|
632
|
+
try {
|
|
633
|
+
imported = parseYaml(yamlString);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
return { success: false, error: `Failed to parse YAML: ${err.message}` };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!imported.rules || !Array.isArray(imported.rules)) {
|
|
639
|
+
return { success: false, error: "Invalid policy: missing 'rules' array." };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let policy = loadPolicy(root);
|
|
643
|
+
if (!policy || mode === "replace") {
|
|
644
|
+
policy = defaultPolicy();
|
|
645
|
+
}
|
|
646
|
+
if (!Array.isArray(policy.rules)) policy.rules = [];
|
|
647
|
+
|
|
648
|
+
let added = 0;
|
|
649
|
+
for (const rule of imported.rules) {
|
|
650
|
+
if (!rule.name) continue;
|
|
651
|
+
|
|
652
|
+
// Check for duplicate names in merge mode
|
|
653
|
+
if (mode === "merge") {
|
|
654
|
+
const exists = policy.rules.some(r => r.name === rule.name);
|
|
655
|
+
if (exists) continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const ruleId = newId("rule");
|
|
659
|
+
policy.rules.push({
|
|
660
|
+
id: ruleId,
|
|
661
|
+
name: rule.name,
|
|
662
|
+
description: rule.description || "",
|
|
663
|
+
match: rule.match || { files: ["**/*"], actions: ["modify"] },
|
|
664
|
+
enforce: rule.enforce || "warn",
|
|
665
|
+
severity: rule.severity || "medium",
|
|
666
|
+
notify: rule.notify || [],
|
|
667
|
+
active: true,
|
|
668
|
+
createdAt: nowIso(),
|
|
669
|
+
imported: true,
|
|
670
|
+
});
|
|
671
|
+
added++;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (imported.notifications) {
|
|
675
|
+
policy.notifications = imported.notifications;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
savePolicy(root, policy);
|
|
679
|
+
|
|
680
|
+
// Log event
|
|
681
|
+
const brain = readBrain(root);
|
|
682
|
+
if (brain) {
|
|
683
|
+
const eventId = newId("evt");
|
|
684
|
+
appendEvent(root, { eventId, type: "policy_imported", at: nowIso(), summary: `Policy imported (${mode}): ${added} rules added` });
|
|
685
|
+
bumpEvents(brain, eventId);
|
|
686
|
+
writeBrain(root, brain);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return { success: true, added, total: policy.rules.length, mode };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// --- Notification helpers ---
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Generate notification payloads for policy violations
|
|
696
|
+
* Returns webhook-ready payloads (actual sending done by integrations)
|
|
697
|
+
*/
|
|
698
|
+
export function generateNotifications(violations, projectName) {
|
|
699
|
+
const notifications = [];
|
|
700
|
+
|
|
701
|
+
for (const v of violations) {
|
|
702
|
+
if (!v.notify || v.notify.length === 0) continue;
|
|
703
|
+
|
|
704
|
+
for (const channel of v.notify) {
|
|
705
|
+
notifications.push({
|
|
706
|
+
channel,
|
|
707
|
+
severity: v.severity,
|
|
708
|
+
rule: v.ruleName,
|
|
709
|
+
description: v.description,
|
|
710
|
+
enforce: v.enforce,
|
|
711
|
+
matchedFiles: v.matchedFiles || [],
|
|
712
|
+
project: projectName || "unknown",
|
|
713
|
+
timestamp: nowIso(),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return notifications;
|
|
719
|
+
}
|