cto-ai-cli 1.3.0 → 3.0.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/DOCS.md +351 -0
- package/README.md +189 -263
- package/dist/action/index.js +25730 -0
- package/dist/api/dashboard.js +2073 -0
- package/dist/api/dashboard.js.map +1 -0
- package/dist/api/server.js +3401 -0
- package/dist/api/server.js.map +1 -0
- package/dist/cli/score.js +1971 -0
- package/dist/cli/v2/index.d.ts +2 -0
- package/dist/cli/v2/index.js +3496 -0
- package/dist/cli/v2/index.js.map +1 -0
- package/dist/engine/index.d.ts +816 -0
- package/dist/engine/index.js +4997 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/govern/index.d.ts +261 -0
- package/dist/govern/index.js +662 -0
- package/dist/govern/index.js.map +1 -0
- package/dist/interact/index.d.ts +234 -0
- package/dist/interact/index.js +1343 -0
- package/dist/interact/index.js.map +1 -0
- package/dist/mcp/v2.d.ts +2 -0
- package/dist/mcp/v2.js +18289 -0
- package/dist/mcp/v2.js.map +1 -0
- package/package.json +56 -25
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
// src/govern/audit.ts
|
|
2
|
+
import { randomUUID, createHash } from "crypto";
|
|
3
|
+
import { readdir, chmod } from "fs/promises";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { userInfo } from "os";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
var CTO_DIR = ".cto-ai";
|
|
8
|
+
var AUDIT_DIR = "audit";
|
|
9
|
+
var MAX_ENTRIES_PER_FILE = 500;
|
|
10
|
+
function getAuditDir() {
|
|
11
|
+
return join(homedir(), CTO_DIR, AUDIT_DIR);
|
|
12
|
+
}
|
|
13
|
+
function getCurrentAuditFile() {
|
|
14
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0].replace(/-/g, "");
|
|
15
|
+
return join(getAuditDir(), `audit_${date}.json`);
|
|
16
|
+
}
|
|
17
|
+
function computeIntegrityHash(entry) {
|
|
18
|
+
const payload = JSON.stringify({
|
|
19
|
+
id: entry.id,
|
|
20
|
+
timestamp: entry.timestamp,
|
|
21
|
+
action: entry.action,
|
|
22
|
+
user: entry.user,
|
|
23
|
+
projectPath: entry.projectPath,
|
|
24
|
+
details: entry.details
|
|
25
|
+
});
|
|
26
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
async function ensureDir(dirPath) {
|
|
29
|
+
const { mkdir } = await import("fs/promises");
|
|
30
|
+
await mkdir(dirPath, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
async function readJSON(filePath) {
|
|
33
|
+
const { readFile: readFile4 } = await import("fs/promises");
|
|
34
|
+
try {
|
|
35
|
+
const content = await readFile4(filePath, "utf-8");
|
|
36
|
+
return JSON.parse(content);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function writeJSON(filePath, data) {
|
|
42
|
+
const { writeFile } = await import("fs/promises");
|
|
43
|
+
await ensureDir(join(filePath, ".."));
|
|
44
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
45
|
+
}
|
|
46
|
+
async function logAudit(action, projectPath, details = {}) {
|
|
47
|
+
const auditDir = getAuditDir();
|
|
48
|
+
await ensureDir(auditDir);
|
|
49
|
+
let currentUser;
|
|
50
|
+
try {
|
|
51
|
+
currentUser = userInfo().username;
|
|
52
|
+
} catch {
|
|
53
|
+
currentUser = process.env.USER ?? process.env.USERNAME ?? "unknown";
|
|
54
|
+
}
|
|
55
|
+
const partialEntry = {
|
|
56
|
+
id: randomUUID().substring(0, 12),
|
|
57
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
58
|
+
action,
|
|
59
|
+
user: currentUser,
|
|
60
|
+
projectPath,
|
|
61
|
+
details
|
|
62
|
+
};
|
|
63
|
+
const entry = {
|
|
64
|
+
...partialEntry,
|
|
65
|
+
integrityHash: computeIntegrityHash(partialEntry)
|
|
66
|
+
};
|
|
67
|
+
const auditFile = getCurrentAuditFile();
|
|
68
|
+
let entries = await readJSON(auditFile) ?? [];
|
|
69
|
+
entries.push(entry);
|
|
70
|
+
if (entries.length > MAX_ENTRIES_PER_FILE) {
|
|
71
|
+
entries = entries.slice(-MAX_ENTRIES_PER_FILE);
|
|
72
|
+
}
|
|
73
|
+
await writeJSON(auditFile, entries);
|
|
74
|
+
try {
|
|
75
|
+
await chmod(auditFile, 384);
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
async function getAuditEntries(options = {}) {
|
|
81
|
+
const auditDir = getAuditDir();
|
|
82
|
+
let files;
|
|
83
|
+
try {
|
|
84
|
+
files = await readdir(auditDir);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const auditFiles = files.filter((f) => f.startsWith("audit_") && f.endsWith(".json")).sort().reverse();
|
|
89
|
+
const allEntries = [];
|
|
90
|
+
const limit = options.limit ?? 100;
|
|
91
|
+
for (const file of auditFiles) {
|
|
92
|
+
if (allEntries.length >= limit) break;
|
|
93
|
+
const entries = await readJSON(join(auditDir, file));
|
|
94
|
+
if (!entries) continue;
|
|
95
|
+
for (const entry of entries.reverse()) {
|
|
96
|
+
if (allEntries.length >= limit) break;
|
|
97
|
+
if (options.projectPath && entry.projectPath !== options.projectPath) continue;
|
|
98
|
+
if (options.action && entry.action !== options.action) continue;
|
|
99
|
+
if (options.since && new Date(entry.timestamp) < options.since) continue;
|
|
100
|
+
allEntries.push(entry);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return allEntries;
|
|
104
|
+
}
|
|
105
|
+
function verifyAuditEntry(entry) {
|
|
106
|
+
const { integrityHash, ...rest } = entry;
|
|
107
|
+
const expected = computeIntegrityHash(rest);
|
|
108
|
+
return expected === integrityHash;
|
|
109
|
+
}
|
|
110
|
+
async function verifyAuditIntegrity() {
|
|
111
|
+
const entries = await getAuditEntries({ limit: 1e4 });
|
|
112
|
+
const invalidEntries = [];
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (!verifyAuditEntry(entry)) {
|
|
115
|
+
invalidEntries.push(entry);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
totalEntries: entries.length,
|
|
120
|
+
validEntries: entries.length - invalidEntries.length,
|
|
121
|
+
invalidEntries
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function purgeOldAuditEntries(retentionDays) {
|
|
125
|
+
const auditDir = getAuditDir();
|
|
126
|
+
let files;
|
|
127
|
+
try {
|
|
128
|
+
files = await readdir(auditDir);
|
|
129
|
+
} catch {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
133
|
+
cutoff.setDate(cutoff.getDate() - retentionDays);
|
|
134
|
+
const cutoffStr = cutoff.toISOString().split("T")[0].replace(/-/g, "");
|
|
135
|
+
let purged = 0;
|
|
136
|
+
const { unlink } = await import("fs/promises");
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
if (!file.startsWith("audit_") || !file.endsWith(".json")) continue;
|
|
139
|
+
const dateStr = file.replace("audit_", "").replace(".json", "");
|
|
140
|
+
if (dateStr < cutoffStr) {
|
|
141
|
+
try {
|
|
142
|
+
await unlink(join(auditDir, file));
|
|
143
|
+
purged++;
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return purged;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/govern/secrets.ts
|
|
152
|
+
import { readFile } from "fs/promises";
|
|
153
|
+
import { resolve, relative } from "path";
|
|
154
|
+
var BUILTIN_PATTERNS = [
|
|
155
|
+
// API Keys
|
|
156
|
+
{ type: "api-key", source: `(?:api[_-]?key|apikey)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{20,})['"]?`, flags: "gi", severity: "critical", description: "API Key" },
|
|
157
|
+
{ type: "api-key", source: "sk-[a-zA-Z0-9]{20,}", flags: "g", severity: "critical", description: "OpenAI/Anthropic API Key" },
|
|
158
|
+
{ type: "api-key", source: "sk-ant-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "Anthropic API Key" },
|
|
159
|
+
// AWS
|
|
160
|
+
{ type: "aws-key", source: "AKIA[0-9A-Z]{16}", flags: "g", severity: "critical", description: "AWS Access Key ID" },
|
|
161
|
+
{ type: "aws-key", source: `(?:aws_secret_access_key|aws_secret)\\s*[:=]\\s*['"]?([a-zA-Z0-9/+=]{40})['"]?`, flags: "gi", severity: "critical", description: "AWS Secret Key" },
|
|
162
|
+
// Private Keys
|
|
163
|
+
{ type: "private-key", source: "-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", flags: "g", severity: "critical", description: "Private Key" },
|
|
164
|
+
{ type: "private-key", source: "-----BEGIN OPENSSH PRIVATE KEY-----", flags: "g", severity: "critical", description: "SSH Private Key" },
|
|
165
|
+
// Passwords
|
|
166
|
+
{ type: "password", source: `(?:password|passwd|pwd)\\s*[:=]\\s*['"]([^'"]{8,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Hardcoded Password" },
|
|
167
|
+
{ type: "password", source: `(?:DB_PASSWORD|DATABASE_PASSWORD|MYSQL_PASSWORD|POSTGRES_PASSWORD)\\s*[:=]\\s*['"]?([^'"{}\\s]{4,})['"]?`, flags: "gi", severity: "high", description: "Database Password" },
|
|
168
|
+
// Tokens
|
|
169
|
+
{ type: "token", source: `(?:bearer|token|auth_token|access_token|refresh_token)\\s*[:=]\\s*['"]([a-zA-Z0-9_\\-.]{20,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Auth Token" },
|
|
170
|
+
{ type: "token", source: "ghp_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub Personal Access Token" },
|
|
171
|
+
{ type: "token", source: "gho_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub OAuth Token" },
|
|
172
|
+
{ type: "token", source: "glpat-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "GitLab Personal Access Token" },
|
|
173
|
+
{ type: "token", source: "npm_[a-zA-Z0-9]{36}", flags: "g", severity: "high", description: "npm Token" },
|
|
174
|
+
// Connection strings
|
|
175
|
+
{ type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
|
|
176
|
+
{ type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
|
|
177
|
+
// Environment variables with secrets
|
|
178
|
+
{ type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" }
|
|
179
|
+
];
|
|
180
|
+
function buildPatterns(customPatterns = []) {
|
|
181
|
+
const patterns = BUILTIN_PATTERNS.map((def) => ({
|
|
182
|
+
type: def.type,
|
|
183
|
+
pattern: new RegExp(def.source, def.flags),
|
|
184
|
+
severity: def.severity,
|
|
185
|
+
description: def.description
|
|
186
|
+
}));
|
|
187
|
+
for (const custom of customPatterns) {
|
|
188
|
+
try {
|
|
189
|
+
patterns.push({
|
|
190
|
+
type: "custom",
|
|
191
|
+
pattern: new RegExp(custom, "gi"),
|
|
192
|
+
severity: "medium",
|
|
193
|
+
description: `Custom pattern: ${custom}`
|
|
194
|
+
});
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return patterns;
|
|
199
|
+
}
|
|
200
|
+
function scanContentForSecrets(content, filePath, customPatterns = []) {
|
|
201
|
+
const findings = [];
|
|
202
|
+
const lines = content.split("\n");
|
|
203
|
+
const allPatterns = buildPatterns(customPatterns);
|
|
204
|
+
for (const secretPattern of allPatterns) {
|
|
205
|
+
for (let i = 0; i < lines.length; i++) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
secretPattern.pattern.lastIndex = 0;
|
|
208
|
+
let match;
|
|
209
|
+
while ((match = secretPattern.pattern.exec(line)) !== null) {
|
|
210
|
+
const matchText = match[0];
|
|
211
|
+
if (isTemplateOrPlaceholder(matchText)) continue;
|
|
212
|
+
findings.push({
|
|
213
|
+
type: secretPattern.type,
|
|
214
|
+
file: filePath,
|
|
215
|
+
line: i + 1,
|
|
216
|
+
match: matchText,
|
|
217
|
+
redacted: redactSecret(matchText),
|
|
218
|
+
severity: secretPattern.severity
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return deduplicateFindings(findings);
|
|
224
|
+
}
|
|
225
|
+
async function scanFileForSecrets(filePath, projectPath, customPatterns = []) {
|
|
226
|
+
try {
|
|
227
|
+
const content = await readFile(filePath, "utf-8");
|
|
228
|
+
const relPath = relative(resolve(projectPath), resolve(filePath));
|
|
229
|
+
return scanContentForSecrets(content, relPath, customPatterns);
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function scanProjectForSecrets(projectPath, filePaths, customPatterns = []) {
|
|
235
|
+
const allFindings = [];
|
|
236
|
+
for (const fp of filePaths) {
|
|
237
|
+
const findings = await scanFileForSecrets(fp, projectPath, customPatterns);
|
|
238
|
+
allFindings.push(...findings);
|
|
239
|
+
}
|
|
240
|
+
return allFindings.sort((a, b) => {
|
|
241
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
242
|
+
return severityOrder[a.severity] - severityOrder[b.severity];
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function sanitizeContent(content, customPatterns = []) {
|
|
246
|
+
let sanitized = content;
|
|
247
|
+
const allPatterns = buildPatterns(customPatterns);
|
|
248
|
+
for (const secretPattern of allPatterns) {
|
|
249
|
+
sanitized = sanitized.replace(secretPattern.pattern, (match) => {
|
|
250
|
+
if (isTemplateOrPlaceholder(match)) return match;
|
|
251
|
+
return redactSecret(match);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return sanitized;
|
|
255
|
+
}
|
|
256
|
+
function redactSecret(value) {
|
|
257
|
+
if (value.length <= 8) return "***REDACTED***";
|
|
258
|
+
const prefix = value.substring(0, 4);
|
|
259
|
+
const suffix = value.substring(value.length - 2);
|
|
260
|
+
return `${prefix}${"*".repeat(Math.min(value.length - 6, 20))}${suffix}`;
|
|
261
|
+
}
|
|
262
|
+
function isTemplateOrPlaceholder(value) {
|
|
263
|
+
const placeholders = [
|
|
264
|
+
/\$\{.*\}/,
|
|
265
|
+
/\{\{.*\}\}/,
|
|
266
|
+
/%[sd]/,
|
|
267
|
+
/<[A-Z_]+>/,
|
|
268
|
+
/YOUR_.*_HERE/i,
|
|
269
|
+
/\bCHANGE_ME\b/i,
|
|
270
|
+
/\bPLACEHOLDER\b/i,
|
|
271
|
+
/\bexample\b/i,
|
|
272
|
+
/\bTODO\b/i,
|
|
273
|
+
/xxx+/i,
|
|
274
|
+
/\breplace.?me\b/i,
|
|
275
|
+
/\bdummy\b/i,
|
|
276
|
+
/\btest_?key\b/i,
|
|
277
|
+
/\bsample\b/i
|
|
278
|
+
];
|
|
279
|
+
return placeholders.some((p) => p.test(value));
|
|
280
|
+
}
|
|
281
|
+
function deduplicateFindings(findings) {
|
|
282
|
+
const seen = /* @__PURE__ */ new Set();
|
|
283
|
+
return findings.filter((f) => {
|
|
284
|
+
const key = `${f.file}:${f.line}:${f.type}:${f.match}`;
|
|
285
|
+
if (seen.has(key)) return false;
|
|
286
|
+
seen.add(key);
|
|
287
|
+
return true;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/engine/graph-utils.ts
|
|
292
|
+
function matchGlob(path, pattern) {
|
|
293
|
+
const regexStr = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "\xA7\xA7").replace(/\*/g, "[^/]*").replace(/§§/g, ".*").replace(/\?/g, ".");
|
|
294
|
+
try {
|
|
295
|
+
return new RegExp(`^${regexStr}$`).test(path);
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/govern/policy.ts
|
|
302
|
+
var DEFAULT_POLICY = {
|
|
303
|
+
version: "1.0",
|
|
304
|
+
name: "default",
|
|
305
|
+
rules: [
|
|
306
|
+
{
|
|
307
|
+
id: "no-env",
|
|
308
|
+
type: "exclude-always",
|
|
309
|
+
pattern: "**/*.env*",
|
|
310
|
+
reason: "Environment files must never be sent to AI",
|
|
311
|
+
enabled: true
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "no-secrets",
|
|
315
|
+
type: "secret-block",
|
|
316
|
+
reason: "Files with detected secrets are blocked",
|
|
317
|
+
enabled: true
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: "min-coverage",
|
|
321
|
+
type: "coverage-minimum",
|
|
322
|
+
threshold: 70,
|
|
323
|
+
reason: "Warn if context coverage drops below 70%",
|
|
324
|
+
enabled: true
|
|
325
|
+
}
|
|
326
|
+
]
|
|
327
|
+
};
|
|
328
|
+
function validateSelection(selection, policies, allFiles) {
|
|
329
|
+
const violations = [];
|
|
330
|
+
const warnings = [];
|
|
331
|
+
const includedPaths = new Set(selection.files.map((f) => f.relativePath));
|
|
332
|
+
for (const rule of policies.rules) {
|
|
333
|
+
if (!rule.enabled) continue;
|
|
334
|
+
switch (rule.type) {
|
|
335
|
+
case "exclude-always": {
|
|
336
|
+
if (!rule.pattern) break;
|
|
337
|
+
const violatingFiles = selection.files.filter(
|
|
338
|
+
(f) => matchGlob(f.relativePath, rule.pattern)
|
|
339
|
+
);
|
|
340
|
+
for (const f of violatingFiles) {
|
|
341
|
+
violations.push({
|
|
342
|
+
rule,
|
|
343
|
+
message: `File "${f.relativePath}" is included but matches exclude-always pattern "${rule.pattern}"`,
|
|
344
|
+
severity: "error"
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "include-always": {
|
|
350
|
+
if (!rule.pattern || !allFiles) break;
|
|
351
|
+
const requiredFiles = allFiles.filter(
|
|
352
|
+
(f) => matchGlob(f.relativePath, rule.pattern)
|
|
353
|
+
);
|
|
354
|
+
for (const f of requiredFiles) {
|
|
355
|
+
if (!includedPaths.has(f.relativePath)) {
|
|
356
|
+
violations.push({
|
|
357
|
+
rule,
|
|
358
|
+
message: `File "${f.relativePath}" matches include-always pattern "${rule.pattern}" but is not included`,
|
|
359
|
+
severity: "warning"
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case "coverage-minimum": {
|
|
366
|
+
const threshold = rule.threshold ?? 70;
|
|
367
|
+
if (selection.coverage.score < threshold) {
|
|
368
|
+
warnings.push({
|
|
369
|
+
rule,
|
|
370
|
+
message: `Coverage ${selection.coverage.score}% is below minimum ${threshold}%`,
|
|
371
|
+
currentValue: selection.coverage.score,
|
|
372
|
+
threshold
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
case "risk-maximum": {
|
|
378
|
+
const threshold = rule.threshold ?? 50;
|
|
379
|
+
if (selection.riskScore > threshold) {
|
|
380
|
+
warnings.push({
|
|
381
|
+
rule,
|
|
382
|
+
message: `Exclusion risk ${selection.riskScore}/100 exceeds maximum ${threshold}`,
|
|
383
|
+
currentValue: selection.riskScore,
|
|
384
|
+
threshold
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case "budget-limit": {
|
|
390
|
+
if (!rule.category || !rule.threshold) break;
|
|
391
|
+
const categoryFiles = selection.files.filter(
|
|
392
|
+
(f) => fileMatchesCategory(f.relativePath, rule.category)
|
|
393
|
+
);
|
|
394
|
+
const categoryTokens = categoryFiles.reduce((s, f) => s + f.tokens, 0);
|
|
395
|
+
const categoryPercent = selection.totalTokens > 0 ? categoryTokens / selection.totalTokens * 100 : 0;
|
|
396
|
+
if (categoryPercent > rule.threshold) {
|
|
397
|
+
warnings.push({
|
|
398
|
+
rule,
|
|
399
|
+
message: `Category "${rule.category}" uses ${Math.round(categoryPercent)}% of budget (max: ${rule.threshold}%)`,
|
|
400
|
+
currentValue: Math.round(categoryPercent),
|
|
401
|
+
threshold: rule.threshold
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
passed: violations.filter((v) => v.severity === "error").length === 0,
|
|
410
|
+
violations,
|
|
411
|
+
warnings
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function addRule(policies, rule) {
|
|
415
|
+
return {
|
|
416
|
+
...policies,
|
|
417
|
+
rules: [...policies.rules, rule]
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function removeRule(policies, ruleId) {
|
|
421
|
+
return {
|
|
422
|
+
...policies,
|
|
423
|
+
rules: policies.rules.filter((r) => r.id !== ruleId)
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function toggleRule(policies, ruleId, enabled) {
|
|
427
|
+
return {
|
|
428
|
+
...policies,
|
|
429
|
+
rules: policies.rules.map(
|
|
430
|
+
(r) => r.id === ruleId ? { ...r, enabled } : r
|
|
431
|
+
)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function fileMatchesCategory(path, category) {
|
|
435
|
+
switch (category) {
|
|
436
|
+
case "test":
|
|
437
|
+
return /\.(test|spec)\.[jt]sx?$/.test(path) || /\/__tests__\//.test(path) || /\/tests?\//.test(path);
|
|
438
|
+
case "config":
|
|
439
|
+
return /\.(config|rc)\.[jt]s$/.test(path) || /\.json$/.test(path) || /\.ya?ml$/.test(path);
|
|
440
|
+
case "docs":
|
|
441
|
+
return /\.(md|txt|rst)$/.test(path);
|
|
442
|
+
case "types":
|
|
443
|
+
return /types?\//i.test(path) || /\.d\.ts$/.test(path);
|
|
444
|
+
default:
|
|
445
|
+
return path.includes(category);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/govern/snapshot.ts
|
|
450
|
+
import { randomUUID as randomUUID2, createHash as createHash2 } from "crypto";
|
|
451
|
+
import "fs/promises";
|
|
452
|
+
function createSnapshot(name, analysis, selection, metadata = {}) {
|
|
453
|
+
const files = selection.files.map((f) => ({
|
|
454
|
+
relativePath: f.relativePath,
|
|
455
|
+
hash: hashString(`${f.relativePath}:${f.tokens}:${f.pruneLevel}`),
|
|
456
|
+
tokens: f.tokens,
|
|
457
|
+
pruneLevel: f.pruneLevel
|
|
458
|
+
}));
|
|
459
|
+
const snapshotData = files.map((f) => `${f.relativePath}:${f.hash}:${f.pruneLevel}`).sort().join("|");
|
|
460
|
+
return {
|
|
461
|
+
id: randomUUID2().substring(0, 8),
|
|
462
|
+
name,
|
|
463
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
464
|
+
hash: hashString(snapshotData),
|
|
465
|
+
projectHash: analysis.hash,
|
|
466
|
+
analysisHash: analysis.hash,
|
|
467
|
+
selectionHash: selection.hash,
|
|
468
|
+
files,
|
|
469
|
+
totalTokens: selection.totalTokens,
|
|
470
|
+
coverageScore: selection.coverage.score,
|
|
471
|
+
riskScore: selection.riskScore,
|
|
472
|
+
metadata
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
async function verifySnapshot(snapshot, currentAnalysis, currentSelection) {
|
|
476
|
+
const currentFiles = new Map(
|
|
477
|
+
currentSelection.files.map((f) => [f.relativePath, f])
|
|
478
|
+
);
|
|
479
|
+
let filesMatched = 0;
|
|
480
|
+
const filesMissing = [];
|
|
481
|
+
const filesChanged = [];
|
|
482
|
+
for (const snapFile of snapshot.files) {
|
|
483
|
+
const current = currentFiles.get(snapFile.relativePath);
|
|
484
|
+
if (!current) {
|
|
485
|
+
filesMissing.push(snapFile.relativePath);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const currentHash2 = hashString(
|
|
489
|
+
`${current.relativePath}:${current.tokens}:${current.pruneLevel}`
|
|
490
|
+
);
|
|
491
|
+
if (currentHash2 === snapFile.hash) {
|
|
492
|
+
filesMatched++;
|
|
493
|
+
} else {
|
|
494
|
+
filesChanged.push(snapFile.relativePath);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const currentSnapshotData = snapshot.files.map((f) => {
|
|
498
|
+
const current = currentFiles.get(f.relativePath);
|
|
499
|
+
if (!current) return `${f.relativePath}:MISSING:MISSING`;
|
|
500
|
+
return `${current.relativePath}:${hashString(`${current.relativePath}:${current.tokens}:${current.pruneLevel}`)}:${current.pruneLevel}`;
|
|
501
|
+
}).sort().join("|");
|
|
502
|
+
const currentHash = hashString(currentSnapshotData);
|
|
503
|
+
const integrityOk = currentHash === snapshot.hash && filesMissing.length === 0 && filesChanged.length === 0;
|
|
504
|
+
return {
|
|
505
|
+
valid: integrityOk,
|
|
506
|
+
snapshotId: snapshot.id,
|
|
507
|
+
filesChecked: snapshot.files.length,
|
|
508
|
+
filesMatched,
|
|
509
|
+
filesMissing,
|
|
510
|
+
filesChanged,
|
|
511
|
+
integrityOk
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function compareSnapshots(older, newer) {
|
|
515
|
+
const olderFiles = new Map(older.files.map((f) => [f.relativePath, f]));
|
|
516
|
+
const newerFiles = new Map(newer.files.map((f) => [f.relativePath, f]));
|
|
517
|
+
const added = [];
|
|
518
|
+
const removed = [];
|
|
519
|
+
const changed = [];
|
|
520
|
+
for (const [path, file] of newerFiles) {
|
|
521
|
+
const old = olderFiles.get(path);
|
|
522
|
+
if (!old) {
|
|
523
|
+
added.push(path);
|
|
524
|
+
} else if (old.hash !== file.hash) {
|
|
525
|
+
changed.push(path);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const path of olderFiles.keys()) {
|
|
529
|
+
if (!newerFiles.has(path)) {
|
|
530
|
+
removed.push(path);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
added,
|
|
535
|
+
removed,
|
|
536
|
+
changed,
|
|
537
|
+
tokenDelta: newer.totalTokens - older.totalTokens,
|
|
538
|
+
coverageDelta: newer.coverageScore - older.coverageScore,
|
|
539
|
+
riskDelta: newer.riskScore - older.riskScore
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function hashString(input) {
|
|
543
|
+
return createHash2("sha256").update(input).digest("hex").substring(0, 16);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/govern/integrity.ts
|
|
547
|
+
import { createHash as createHash3 } from "crypto";
|
|
548
|
+
import { readFile as readFile3, chmod as chmod2, readdir as readdir2, stat } from "fs/promises";
|
|
549
|
+
import { join as join2 } from "path";
|
|
550
|
+
function hashContent(content) {
|
|
551
|
+
return createHash3("sha256").update(content).digest("hex");
|
|
552
|
+
}
|
|
553
|
+
async function hashFile(filePath) {
|
|
554
|
+
try {
|
|
555
|
+
const content = await readFile3(filePath);
|
|
556
|
+
return hashContent(content);
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function buildManifest(projectDir) {
|
|
562
|
+
const entries = [];
|
|
563
|
+
async function scanDir(dir, type) {
|
|
564
|
+
let files;
|
|
565
|
+
try {
|
|
566
|
+
files = await readdir2(dir);
|
|
567
|
+
} catch {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
for (const file of files) {
|
|
571
|
+
const fullPath = join2(dir, file);
|
|
572
|
+
try {
|
|
573
|
+
const fileStat = await stat(fullPath);
|
|
574
|
+
if (fileStat.isFile()) {
|
|
575
|
+
const hash = await hashFile(fullPath);
|
|
576
|
+
if (hash) {
|
|
577
|
+
entries.push({
|
|
578
|
+
filePath: fullPath,
|
|
579
|
+
hash,
|
|
580
|
+
size: fileStat.size,
|
|
581
|
+
createdAt: fileStat.mtime,
|
|
582
|
+
type
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
await scanDir(join2(projectDir, "snapshots"), "snapshot");
|
|
591
|
+
await scanDir(join2(projectDir, "audit"), "audit");
|
|
592
|
+
await scanDir(projectDir, "config");
|
|
593
|
+
return {
|
|
594
|
+
version: "2.0",
|
|
595
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
596
|
+
entries
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
async function verifyManifest(manifest) {
|
|
600
|
+
const invalidFiles = [];
|
|
601
|
+
const missingFiles = [];
|
|
602
|
+
for (const entry of manifest.entries) {
|
|
603
|
+
const currentHash = await hashFile(entry.filePath);
|
|
604
|
+
if (currentHash === null) {
|
|
605
|
+
missingFiles.push(entry.filePath);
|
|
606
|
+
} else if (currentHash !== entry.hash) {
|
|
607
|
+
invalidFiles.push(entry.filePath);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
totalFiles: manifest.entries.length,
|
|
612
|
+
validFiles: manifest.entries.length - invalidFiles.length - missingFiles.length,
|
|
613
|
+
invalidFiles,
|
|
614
|
+
missingFiles
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
async function securePermissions(dirPath) {
|
|
618
|
+
let count = 0;
|
|
619
|
+
try {
|
|
620
|
+
await chmod2(dirPath, 448);
|
|
621
|
+
count++;
|
|
622
|
+
const files = await readdir2(dirPath);
|
|
623
|
+
for (const file of files) {
|
|
624
|
+
try {
|
|
625
|
+
const fullPath = join2(dirPath, file);
|
|
626
|
+
const fileStat = await stat(fullPath);
|
|
627
|
+
if (fileStat.isFile()) {
|
|
628
|
+
await chmod2(fullPath, 384);
|
|
629
|
+
count++;
|
|
630
|
+
}
|
|
631
|
+
} catch {
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
}
|
|
636
|
+
return count;
|
|
637
|
+
}
|
|
638
|
+
export {
|
|
639
|
+
DEFAULT_POLICY,
|
|
640
|
+
addRule,
|
|
641
|
+
buildManifest,
|
|
642
|
+
compareSnapshots,
|
|
643
|
+
createSnapshot,
|
|
644
|
+
getAuditEntries,
|
|
645
|
+
hashContent,
|
|
646
|
+
hashFile,
|
|
647
|
+
logAudit,
|
|
648
|
+
purgeOldAuditEntries,
|
|
649
|
+
removeRule,
|
|
650
|
+
sanitizeContent,
|
|
651
|
+
scanContentForSecrets,
|
|
652
|
+
scanFileForSecrets,
|
|
653
|
+
scanProjectForSecrets,
|
|
654
|
+
securePermissions,
|
|
655
|
+
toggleRule,
|
|
656
|
+
validateSelection,
|
|
657
|
+
verifyAuditEntry,
|
|
658
|
+
verifyAuditIntegrity,
|
|
659
|
+
verifyManifest,
|
|
660
|
+
verifySnapshot
|
|
661
|
+
};
|
|
662
|
+
//# sourceMappingURL=index.js.map
|