cto-ai-cli 3.1.0 → 4.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.
- package/DOCS.md +352 -0
- package/README.md +192 -15
- package/dist/action/index.js +629 -83
- package/dist/api/dashboard.js +107 -23
- package/dist/api/dashboard.js.map +1 -1
- package/dist/api/server.js +108 -24
- package/dist/api/server.js.map +1 -1
- package/dist/cli/gateway.js +2925 -0
- package/dist/cli/score.js +3015 -237
- package/dist/cli/v2/index.js +133 -49
- package/dist/cli/v2/index.js.map +1 -1
- package/dist/engine/index.d.ts +85 -1
- package/dist/engine/index.js +665 -42
- package/dist/engine/index.js.map +1 -1
- package/dist/gateway/index.d.ts +281 -0
- package/dist/gateway/index.js +2803 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/govern/index.d.ts +67 -3
- package/dist/govern/index.js +462 -23
- package/dist/govern/index.js.map +1 -1
- package/dist/interact/index.js +108 -24
- package/dist/interact/index.js.map +1 -1
- package/dist/mcp/v2.js +130 -46
- package/dist/mcp/v2.js.map +1 -1
- package/package.json +3 -2
package/dist/cli/score.js
CHANGED
|
@@ -1,68 +1,78 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { createHash } from "crypto";
|
|
11
|
-
|
|
12
|
-
// src/types/engine.ts
|
|
13
|
-
var DEFAULT_RISK_WEIGHTS = {
|
|
14
|
-
hub: 30,
|
|
15
|
-
typeProvider: 25,
|
|
16
|
-
complexity: 15,
|
|
17
|
-
recency: 15,
|
|
18
|
-
config: 10,
|
|
19
|
-
churn: 5
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
20
10
|
};
|
|
21
11
|
|
|
22
|
-
// src/types/
|
|
23
|
-
var
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
config: ["json", "yml", "yaml", "toml"],
|
|
29
|
-
docs: ["md", "txt", "rst"]
|
|
30
|
-
},
|
|
31
|
-
ignore: {
|
|
32
|
-
dirs: ["node_modules", "dist", "build", ".git", "coverage", "__pycache__", ".next", "vendor", ".cto"],
|
|
33
|
-
patterns: ["*.min.js", "*.map", "*.lock", "*.generated.*"]
|
|
34
|
-
},
|
|
35
|
-
maxDepth: 20
|
|
36
|
-
},
|
|
37
|
-
risk: {
|
|
38
|
-
weights: {
|
|
12
|
+
// src/types/engine.ts
|
|
13
|
+
var DEFAULT_RISK_WEIGHTS;
|
|
14
|
+
var init_engine = __esm({
|
|
15
|
+
"src/types/engine.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
DEFAULT_RISK_WEIGHTS = {
|
|
39
18
|
hub: 30,
|
|
40
19
|
typeProvider: 25,
|
|
41
20
|
complexity: 15,
|
|
42
21
|
recency: 15,
|
|
43
22
|
config: 10,
|
|
44
23
|
churn: 5
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
interaction: {
|
|
48
|
-
defaultBudget: 5e4,
|
|
49
|
-
defaultModel: "claude-sonnet-4"
|
|
50
|
-
},
|
|
51
|
-
tokens: {
|
|
52
|
-
method: "chars4"
|
|
53
|
-
},
|
|
54
|
-
governance: {
|
|
55
|
-
auditEnabled: true,
|
|
56
|
-
secretDetection: true,
|
|
57
|
-
retentionDays: 90
|
|
24
|
+
};
|
|
58
25
|
}
|
|
59
|
-
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// src/types/config.ts
|
|
29
|
+
var DEFAULT_CONFIG;
|
|
30
|
+
var init_config = __esm({
|
|
31
|
+
"src/types/config.ts"() {
|
|
32
|
+
"use strict";
|
|
33
|
+
DEFAULT_CONFIG = {
|
|
34
|
+
version: "2.0",
|
|
35
|
+
analysis: {
|
|
36
|
+
extensions: {
|
|
37
|
+
code: ["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "kt", "rb", "php", "c", "cpp", "h", "hpp", "cs"],
|
|
38
|
+
config: ["json", "yml", "yaml", "toml"],
|
|
39
|
+
docs: ["md", "txt", "rst"]
|
|
40
|
+
},
|
|
41
|
+
ignore: {
|
|
42
|
+
dirs: ["node_modules", "dist", "build", ".git", "coverage", "__pycache__", ".next", "vendor", ".cto"],
|
|
43
|
+
patterns: ["*.min.js", "*.map", "*.lock", "*.generated.*"]
|
|
44
|
+
},
|
|
45
|
+
maxDepth: 20
|
|
46
|
+
},
|
|
47
|
+
risk: {
|
|
48
|
+
weights: {
|
|
49
|
+
hub: 30,
|
|
50
|
+
typeProvider: 25,
|
|
51
|
+
complexity: 15,
|
|
52
|
+
recency: 15,
|
|
53
|
+
config: 10,
|
|
54
|
+
churn: 5
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
interaction: {
|
|
58
|
+
defaultBudget: 5e4,
|
|
59
|
+
defaultModel: "claude-sonnet-4"
|
|
60
|
+
},
|
|
61
|
+
tokens: {
|
|
62
|
+
method: "chars4"
|
|
63
|
+
},
|
|
64
|
+
governance: {
|
|
65
|
+
auditEnabled: true,
|
|
66
|
+
secretDetection: true,
|
|
67
|
+
retentionDays: 90
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
});
|
|
60
72
|
|
|
61
73
|
// src/engine/tokenizer.ts
|
|
62
74
|
import { encodingForModel } from "js-tiktoken";
|
|
63
75
|
import { readFile, stat } from "fs/promises";
|
|
64
|
-
var CHARS_PER_TOKEN = 4;
|
|
65
|
-
var encoder = null;
|
|
66
76
|
function getEncoder() {
|
|
67
77
|
if (!encoder) {
|
|
68
78
|
encoder = encodingForModel("claude-3-5-sonnet-20241022");
|
|
@@ -87,12 +97,19 @@ function estimateTokens(content, sizeInBytes, method = "chars4") {
|
|
|
87
97
|
}
|
|
88
98
|
return countTokensChars4(sizeInBytes);
|
|
89
99
|
}
|
|
100
|
+
var CHARS_PER_TOKEN, encoder;
|
|
101
|
+
var init_tokenizer = __esm({
|
|
102
|
+
"src/engine/tokenizer.ts"() {
|
|
103
|
+
"use strict";
|
|
104
|
+
CHARS_PER_TOKEN = 4;
|
|
105
|
+
encoder = null;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
90
108
|
|
|
91
109
|
// src/engine/graph.ts
|
|
92
110
|
import { Project, SyntaxKind } from "ts-morph";
|
|
93
111
|
import { resolve, relative, dirname, join } from "path";
|
|
94
112
|
import { existsSync } from "fs";
|
|
95
|
-
var TS_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"]);
|
|
96
113
|
function createProject(projectPath, filePaths) {
|
|
97
114
|
const tsConfigPath = join(projectPath, "tsconfig.json");
|
|
98
115
|
const hasTsConfig = existsSync(tsConfigPath);
|
|
@@ -191,41 +208,6 @@ function buildProjectGraph(projectPath, files) {
|
|
|
191
208
|
enrichComplexity(project, absPath, files);
|
|
192
209
|
return { nodes, edges, hubs, leaves, orphans, clusters };
|
|
193
210
|
}
|
|
194
|
-
var UnionFind = class {
|
|
195
|
-
parent;
|
|
196
|
-
rank;
|
|
197
|
-
constructor(nodes) {
|
|
198
|
-
this.parent = /* @__PURE__ */ new Map();
|
|
199
|
-
this.rank = /* @__PURE__ */ new Map();
|
|
200
|
-
for (const n of nodes) {
|
|
201
|
-
this.parent.set(n, n);
|
|
202
|
-
this.rank.set(n, 0);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
find(x) {
|
|
206
|
-
const p = this.parent.get(x);
|
|
207
|
-
if (p === void 0) return x;
|
|
208
|
-
if (p !== x) {
|
|
209
|
-
this.parent.set(x, this.find(p));
|
|
210
|
-
}
|
|
211
|
-
return this.parent.get(x);
|
|
212
|
-
}
|
|
213
|
-
union(a, b) {
|
|
214
|
-
const ra = this.find(a);
|
|
215
|
-
const rb = this.find(b);
|
|
216
|
-
if (ra === rb) return;
|
|
217
|
-
const rankA = this.rank.get(ra) ?? 0;
|
|
218
|
-
const rankB = this.rank.get(rb) ?? 0;
|
|
219
|
-
if (rankA < rankB) {
|
|
220
|
-
this.parent.set(ra, rb);
|
|
221
|
-
} else if (rankA > rankB) {
|
|
222
|
-
this.parent.set(rb, ra);
|
|
223
|
-
} else {
|
|
224
|
-
this.parent.set(rb, ra);
|
|
225
|
-
this.rank.set(ra, rankA + 1);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
211
|
function detectClusters(nodes, edges, files) {
|
|
230
212
|
const uf = new UnionFind(nodes);
|
|
231
213
|
for (const edge of edges) {
|
|
@@ -363,6 +345,48 @@ function emptyGraph(files) {
|
|
|
363
345
|
clusters: []
|
|
364
346
|
};
|
|
365
347
|
}
|
|
348
|
+
var TS_EXTENSIONS, UnionFind;
|
|
349
|
+
var init_graph = __esm({
|
|
350
|
+
"src/engine/graph.ts"() {
|
|
351
|
+
"use strict";
|
|
352
|
+
TS_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"]);
|
|
353
|
+
UnionFind = class {
|
|
354
|
+
parent;
|
|
355
|
+
rank;
|
|
356
|
+
constructor(nodes) {
|
|
357
|
+
this.parent = /* @__PURE__ */ new Map();
|
|
358
|
+
this.rank = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const n of nodes) {
|
|
360
|
+
this.parent.set(n, n);
|
|
361
|
+
this.rank.set(n, 0);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
find(x) {
|
|
365
|
+
const p = this.parent.get(x);
|
|
366
|
+
if (p === void 0) return x;
|
|
367
|
+
if (p !== x) {
|
|
368
|
+
this.parent.set(x, this.find(p));
|
|
369
|
+
}
|
|
370
|
+
return this.parent.get(x);
|
|
371
|
+
}
|
|
372
|
+
union(a, b) {
|
|
373
|
+
const ra = this.find(a);
|
|
374
|
+
const rb = this.find(b);
|
|
375
|
+
if (ra === rb) return;
|
|
376
|
+
const rankA = this.rank.get(ra) ?? 0;
|
|
377
|
+
const rankB = this.rank.get(rb) ?? 0;
|
|
378
|
+
if (rankA < rankB) {
|
|
379
|
+
this.parent.set(ra, rb);
|
|
380
|
+
} else if (rankA > rankB) {
|
|
381
|
+
this.parent.set(rb, ra);
|
|
382
|
+
} else {
|
|
383
|
+
this.parent.set(rb, ra);
|
|
384
|
+
this.rank.set(ra, rankA + 1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
});
|
|
366
390
|
|
|
367
391
|
// src/engine/risk.ts
|
|
368
392
|
function scoreAllFiles(files, graph, weights = DEFAULT_RISK_WEIGHTS) {
|
|
@@ -480,8 +504,17 @@ function computeTypeProviderUsage(files, graph) {
|
|
|
480
504
|
}
|
|
481
505
|
return usage;
|
|
482
506
|
}
|
|
507
|
+
var init_risk = __esm({
|
|
508
|
+
"src/engine/risk.ts"() {
|
|
509
|
+
"use strict";
|
|
510
|
+
init_engine();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
483
513
|
|
|
484
514
|
// src/engine/analyzer.ts
|
|
515
|
+
import { readFile as readFile2, readdir, stat as stat2 } from "fs/promises";
|
|
516
|
+
import { join as join2, extname, relative as relative2, resolve as resolve2, basename as basename2 } from "path";
|
|
517
|
+
import { createHash } from "crypto";
|
|
485
518
|
function matchesPattern(filename, patterns) {
|
|
486
519
|
for (const pattern of patterns) {
|
|
487
520
|
if (pattern.startsWith("*.")) {
|
|
@@ -544,10 +577,6 @@ async function walkProject(rootPath, options) {
|
|
|
544
577
|
await walk(rootPath, 0);
|
|
545
578
|
return results;
|
|
546
579
|
}
|
|
547
|
-
var TYPE_PATTERNS = [/types?\//i, /\.d\.ts$/, /interfaces?\//i];
|
|
548
|
-
var TEST_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\/__tests__\//, /\/tests?\//];
|
|
549
|
-
var CONFIG_PATTERNS = [/\.config\.[jt]s$/, /rc\.[jt]s$/, /\.env/, /tsconfig/, /package\.json$/, /\.yml$/, /\.yaml$/, /\.toml$/];
|
|
550
|
-
var ENTRY_PATTERNS = [/^index\.[jt]sx?$/, /^main\.[jt]sx?$/, /^app\.[jt]sx?$/, /^server\.[jt]sx?$/];
|
|
551
580
|
function classifyFileKind(relativePath) {
|
|
552
581
|
const filename = basename2(relativePath);
|
|
553
582
|
if (TYPE_PATTERNS.some((p) => p.test(relativePath))) return "type";
|
|
@@ -706,46 +735,60 @@ function mergeConfig(base, overrides) {
|
|
|
706
735
|
}
|
|
707
736
|
};
|
|
708
737
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
738
|
+
var TYPE_PATTERNS, TEST_PATTERNS, CONFIG_PATTERNS, ENTRY_PATTERNS;
|
|
739
|
+
var init_analyzer = __esm({
|
|
740
|
+
"src/engine/analyzer.ts"() {
|
|
741
|
+
"use strict";
|
|
742
|
+
init_engine();
|
|
743
|
+
init_config();
|
|
744
|
+
init_tokenizer();
|
|
745
|
+
init_graph();
|
|
746
|
+
init_risk();
|
|
747
|
+
TYPE_PATTERNS = [/types?\//i, /\.d\.ts$/, /interfaces?\//i];
|
|
748
|
+
TEST_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\/__tests__\//, /\/tests?\//];
|
|
749
|
+
CONFIG_PATTERNS = [/\.config\.[jt]s$/, /rc\.[jt]s$/, /\.env/, /tsconfig/, /package\.json$/, /\.yml$/, /\.yaml$/, /\.toml$/];
|
|
750
|
+
ENTRY_PATTERNS = [/^index\.[jt]sx?$/, /^main\.[jt]sx?$/, /^app\.[jt]sx?$/, /^server\.[jt]sx?$/];
|
|
751
|
+
}
|
|
752
|
+
});
|
|
712
753
|
|
|
713
754
|
// src/govern/secrets.ts
|
|
755
|
+
var secrets_exports = {};
|
|
756
|
+
__export(secrets_exports, {
|
|
757
|
+
DEFAULT_AUDIT_CONFIG: () => DEFAULT_AUDIT_CONFIG,
|
|
758
|
+
addToAllowlist: () => addToAllowlist,
|
|
759
|
+
auditProject: () => auditProject,
|
|
760
|
+
filterByAllowlist: () => filterByAllowlist,
|
|
761
|
+
generatePreCommitHook: () => generatePreCommitHook,
|
|
762
|
+
getChangedFiles: () => getChangedFiles,
|
|
763
|
+
loadAllowlist: () => loadAllowlist,
|
|
764
|
+
loadAuditConfig: () => loadAuditConfig,
|
|
765
|
+
sanitizeContent: () => sanitizeContent,
|
|
766
|
+
saveAllowlist: () => saveAllowlist,
|
|
767
|
+
saveAuditConfig: () => saveAuditConfig,
|
|
768
|
+
scanContentForHighEntropy: () => scanContentForHighEntropy,
|
|
769
|
+
scanContentForSecrets: () => scanContentForSecrets,
|
|
770
|
+
scanFileForSecrets: () => scanFileForSecrets,
|
|
771
|
+
scanProjectForSecrets: () => scanProjectForSecrets
|
|
772
|
+
});
|
|
714
773
|
import { readFile as readFile3 } from "fs/promises";
|
|
715
|
-
import {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
{ type: "password", source: `(?:DB_PASSWORD|DATABASE_PASSWORD|MYSQL_PASSWORD|POSTGRES_PASSWORD)\\s*[:=]\\s*['"]?([^'"{}\\s]{4,})['"]?`, flags: "gi", severity: "high", description: "Database Password" },
|
|
730
|
-
// Tokens
|
|
731
|
-
{ 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" },
|
|
732
|
-
{ type: "token", source: "ghp_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub Personal Access Token" },
|
|
733
|
-
{ type: "token", source: "gho_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub OAuth Token" },
|
|
734
|
-
{ type: "token", source: "glpat-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "GitLab Personal Access Token" },
|
|
735
|
-
{ type: "token", source: "npm_[a-zA-Z0-9]{36}", flags: "g", severity: "high", description: "npm Token" },
|
|
736
|
-
// Connection strings
|
|
737
|
-
{ type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
|
|
738
|
-
{ type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
|
|
739
|
-
// Environment variables with secrets
|
|
740
|
-
{ type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" }
|
|
741
|
-
];
|
|
774
|
+
import { readFileSync, existsSync as existsSync2, mkdirSync, writeFileSync } from "fs";
|
|
775
|
+
import { resolve as resolve3, relative as relative3, join as join3, dirname as dirname2 } from "path";
|
|
776
|
+
import { createHash as createHash2 } from "crypto";
|
|
777
|
+
function getBuiltinPatterns() {
|
|
778
|
+
if (!_cachedBuiltinPatterns) {
|
|
779
|
+
_cachedBuiltinPatterns = BUILTIN_PATTERNS.map((def) => ({
|
|
780
|
+
type: def.type,
|
|
781
|
+
pattern: new RegExp(def.source, def.flags),
|
|
782
|
+
severity: def.severity,
|
|
783
|
+
description: def.description
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
return _cachedBuiltinPatterns;
|
|
787
|
+
}
|
|
742
788
|
function buildPatterns(customPatterns = []) {
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
severity: def.severity,
|
|
747
|
-
description: def.description
|
|
748
|
-
}));
|
|
789
|
+
const builtins = getBuiltinPatterns();
|
|
790
|
+
if (customPatterns.length === 0) return builtins;
|
|
791
|
+
const patterns = [...builtins];
|
|
749
792
|
for (const custom of customPatterns) {
|
|
750
793
|
try {
|
|
751
794
|
patterns.push({
|
|
@@ -759,7 +802,7 @@ function buildPatterns(customPatterns = []) {
|
|
|
759
802
|
}
|
|
760
803
|
return patterns;
|
|
761
804
|
}
|
|
762
|
-
function scanContentForSecrets(content, filePath, customPatterns = []) {
|
|
805
|
+
function scanContentForSecrets(content, filePath, customPatterns = [], extraPiiSafeDomains) {
|
|
763
806
|
const findings = [];
|
|
764
807
|
const lines = content.split("\n");
|
|
765
808
|
const allPatterns = buildPatterns(customPatterns);
|
|
@@ -771,6 +814,7 @@ function scanContentForSecrets(content, filePath, customPatterns = []) {
|
|
|
771
814
|
while ((match = secretPattern.pattern.exec(line)) !== null) {
|
|
772
815
|
const matchText = match[0];
|
|
773
816
|
if (isTemplateOrPlaceholder(matchText)) continue;
|
|
817
|
+
if (secretPattern.type === "pii" && isSafeEmail(matchText, extraPiiSafeDomains)) continue;
|
|
774
818
|
findings.push({
|
|
775
819
|
type: secretPattern.type,
|
|
776
820
|
file: filePath,
|
|
@@ -793,6 +837,28 @@ async function scanFileForSecrets(filePath, projectPath, customPatterns = []) {
|
|
|
793
837
|
return [];
|
|
794
838
|
}
|
|
795
839
|
}
|
|
840
|
+
async function scanProjectForSecrets(projectPath, filePaths, customPatterns = []) {
|
|
841
|
+
const allFindings = [];
|
|
842
|
+
for (const fp of filePaths) {
|
|
843
|
+
const findings = await scanFileForSecrets(fp, projectPath, customPatterns);
|
|
844
|
+
allFindings.push(...findings);
|
|
845
|
+
}
|
|
846
|
+
return allFindings.sort((a, b) => {
|
|
847
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
848
|
+
return severityOrder[a.severity] - severityOrder[b.severity];
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
function sanitizeContent(content, customPatterns = []) {
|
|
852
|
+
let sanitized = content;
|
|
853
|
+
const allPatterns = buildPatterns(customPatterns);
|
|
854
|
+
for (const secretPattern of allPatterns) {
|
|
855
|
+
sanitized = sanitized.replace(secretPattern.pattern, (match) => {
|
|
856
|
+
if (isTemplateOrPlaceholder(match)) return match;
|
|
857
|
+
return redactSecret(match);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return sanitized;
|
|
861
|
+
}
|
|
796
862
|
function redactSecret(value) {
|
|
797
863
|
if (value.length <= 8) return "***REDACTED***";
|
|
798
864
|
const prefix = value.substring(0, 4);
|
|
@@ -818,6 +884,14 @@ function isTemplateOrPlaceholder(value) {
|
|
|
818
884
|
];
|
|
819
885
|
return placeholders.some((p) => p.test(value));
|
|
820
886
|
}
|
|
887
|
+
function isSafeEmail(value, extraDomains) {
|
|
888
|
+
const match = value.match(/@([a-zA-Z0-9.-]+)$/);
|
|
889
|
+
if (!match) return false;
|
|
890
|
+
const domain = match[1].toLowerCase();
|
|
891
|
+
if (PII_SAFE_EMAIL_DOMAINS.has(domain)) return true;
|
|
892
|
+
if (extraDomains && extraDomains.has(domain)) return true;
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
821
895
|
function deduplicateFindings(findings) {
|
|
822
896
|
const seen = /* @__PURE__ */ new Set();
|
|
823
897
|
return findings.filter((f) => {
|
|
@@ -827,13 +901,453 @@ function deduplicateFindings(findings) {
|
|
|
827
901
|
return true;
|
|
828
902
|
});
|
|
829
903
|
}
|
|
904
|
+
function fingerprintFinding(f) {
|
|
905
|
+
return createHash2("sha256").update(`${f.file}:${f.type}:${f.match}`).digest("hex").slice(0, 32);
|
|
906
|
+
}
|
|
907
|
+
function getAllowlistPath(projectPath) {
|
|
908
|
+
return join3(projectPath, ".cto", "audit", "allowlist.json");
|
|
909
|
+
}
|
|
910
|
+
function loadAllowlist(projectPath) {
|
|
911
|
+
const filePath = getAllowlistPath(projectPath);
|
|
912
|
+
if (!existsSync2(filePath)) return [];
|
|
913
|
+
try {
|
|
914
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
915
|
+
} catch {
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
function saveAllowlist(projectPath, entries) {
|
|
920
|
+
const filePath = getAllowlistPath(projectPath);
|
|
921
|
+
mkdirSync(dirname2(filePath), { recursive: true });
|
|
922
|
+
writeFileSync(filePath, JSON.stringify(entries, null, 2) + "\n");
|
|
923
|
+
}
|
|
924
|
+
function addToAllowlist(projectPath, finding, reason, reviewedBy = "manual") {
|
|
925
|
+
const entries = loadAllowlist(projectPath);
|
|
926
|
+
const entry = {
|
|
927
|
+
fingerprint: fingerprintFinding(finding),
|
|
928
|
+
file: finding.file,
|
|
929
|
+
type: finding.type,
|
|
930
|
+
redacted: finding.redacted,
|
|
931
|
+
reason,
|
|
932
|
+
reviewedBy,
|
|
933
|
+
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
934
|
+
};
|
|
935
|
+
const existing = entries.findIndex((e) => e.fingerprint === entry.fingerprint);
|
|
936
|
+
if (existing >= 0) {
|
|
937
|
+
entries[existing] = entry;
|
|
938
|
+
} else {
|
|
939
|
+
entries.push(entry);
|
|
940
|
+
}
|
|
941
|
+
saveAllowlist(projectPath, entries);
|
|
942
|
+
return entry;
|
|
943
|
+
}
|
|
944
|
+
function filterByAllowlist(findings, projectPath) {
|
|
945
|
+
const allowlist = loadAllowlist(projectPath);
|
|
946
|
+
if (allowlist.length === 0) return { filtered: findings, allowed: [] };
|
|
947
|
+
const allowedFingerprints = new Set(allowlist.map((e) => e.fingerprint));
|
|
948
|
+
const filtered = [];
|
|
949
|
+
const allowed = [];
|
|
950
|
+
for (const f of findings) {
|
|
951
|
+
if (allowedFingerprints.has(fingerprintFinding(f))) {
|
|
952
|
+
allowed.push(f);
|
|
953
|
+
} else {
|
|
954
|
+
filtered.push(f);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return { filtered, allowed };
|
|
958
|
+
}
|
|
959
|
+
function getHashCachePath(projectPath) {
|
|
960
|
+
return join3(projectPath, ".cto", "audit", ".hashcache.json");
|
|
961
|
+
}
|
|
962
|
+
function loadHashCache(projectPath) {
|
|
963
|
+
const filePath = getHashCachePath(projectPath);
|
|
964
|
+
if (!existsSync2(filePath)) return {};
|
|
965
|
+
try {
|
|
966
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
967
|
+
} catch {
|
|
968
|
+
return {};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function saveHashCache(projectPath, cache) {
|
|
972
|
+
const filePath = getHashCachePath(projectPath);
|
|
973
|
+
mkdirSync(dirname2(filePath), { recursive: true });
|
|
974
|
+
writeFileSync(filePath, JSON.stringify(cache));
|
|
975
|
+
}
|
|
976
|
+
function hashContent(content) {
|
|
977
|
+
return createHash2("sha256").update(content).digest("hex").slice(0, 16);
|
|
978
|
+
}
|
|
979
|
+
function getChangedFiles(projectPath, filePaths) {
|
|
980
|
+
const oldCache = loadHashCache(projectPath);
|
|
981
|
+
const newCache = {};
|
|
982
|
+
const changed = [];
|
|
983
|
+
const unchanged = [];
|
|
984
|
+
for (const fp of filePaths) {
|
|
985
|
+
try {
|
|
986
|
+
const content = readFileSync(fp, "utf-8");
|
|
987
|
+
const relPath = relative3(resolve3(projectPath), resolve3(fp));
|
|
988
|
+
const hash = hashContent(content);
|
|
989
|
+
newCache[relPath] = hash;
|
|
990
|
+
if (oldCache[relPath] === hash) {
|
|
991
|
+
unchanged.push(fp);
|
|
992
|
+
} else {
|
|
993
|
+
changed.push(fp);
|
|
994
|
+
}
|
|
995
|
+
} catch {
|
|
996
|
+
changed.push(fp);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return { changed, unchanged, cache: newCache };
|
|
1000
|
+
}
|
|
1001
|
+
function getAuditConfigPath(projectPath) {
|
|
1002
|
+
return join3(projectPath, ".cto", "audit", "config.json");
|
|
1003
|
+
}
|
|
1004
|
+
function loadAuditConfig(projectPath) {
|
|
1005
|
+
const filePath = getAuditConfigPath(projectPath);
|
|
1006
|
+
if (!existsSync2(filePath)) return { ...DEFAULT_AUDIT_CONFIG };
|
|
1007
|
+
try {
|
|
1008
|
+
const loaded = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
1009
|
+
return { ...DEFAULT_AUDIT_CONFIG, ...loaded };
|
|
1010
|
+
} catch {
|
|
1011
|
+
return { ...DEFAULT_AUDIT_CONFIG };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
function saveAuditConfig(projectPath, config) {
|
|
1015
|
+
const filePath = getAuditConfigPath(projectPath);
|
|
1016
|
+
mkdirSync(dirname2(filePath), { recursive: true });
|
|
1017
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
1018
|
+
}
|
|
1019
|
+
function applySeverityOverrides(findings, overrides) {
|
|
1020
|
+
if (Object.keys(overrides).length === 0) return findings;
|
|
1021
|
+
return findings.map((f) => {
|
|
1022
|
+
const override = overrides[f.type];
|
|
1023
|
+
if (override) return { ...f, severity: override };
|
|
1024
|
+
return f;
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
function generatePreCommitHook(projectPath, hookType = "husky") {
|
|
1028
|
+
const hookContent = `#!/bin/sh
|
|
1029
|
+
# CTO Secret Detection \u2014 Pre-commit hook
|
|
1030
|
+
# Auto-generated by: npx cto-ai-cli --audit --init-hook
|
|
1031
|
+
# Scans ONLY staged files for secrets before allowing commit.
|
|
1032
|
+
|
|
1033
|
+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
|
|
1034
|
+
|
|
1035
|
+
if [ -z "$STAGED_FILES" ]; then
|
|
1036
|
+
exit 0
|
|
1037
|
+
fi
|
|
1038
|
+
|
|
1039
|
+
echo "\u{1F50D} CTO: Scanning $(echo "$STAGED_FILES" | wc -l | tr -d ' ') staged files for secrets..."
|
|
1040
|
+
|
|
1041
|
+
# Write staged files to temp list
|
|
1042
|
+
TMPFILE=$(mktemp)
|
|
1043
|
+
echo "$STAGED_FILES" > "$TMPFILE"
|
|
1044
|
+
|
|
1045
|
+
# Run audit in CI mode on staged files only
|
|
1046
|
+
CI=true npx cto-ai-cli --audit --files "$TMPFILE"
|
|
1047
|
+
RESULT=$?
|
|
1048
|
+
|
|
1049
|
+
rm -f "$TMPFILE"
|
|
1050
|
+
|
|
1051
|
+
if [ $RESULT -ne 0 ]; then
|
|
1052
|
+
echo ""
|
|
1053
|
+
echo "\u274C Commit blocked: secrets detected in staged files."
|
|
1054
|
+
echo " Run 'npx cto-ai-cli --audit' to see details."
|
|
1055
|
+
echo " Use allowlist to mark reviewed findings as safe."
|
|
1056
|
+
echo ""
|
|
1057
|
+
exit 1
|
|
1058
|
+
fi
|
|
1059
|
+
|
|
1060
|
+
echo "\u2705 No secrets detected. Proceeding with commit."
|
|
1061
|
+
`;
|
|
1062
|
+
let hookPath;
|
|
1063
|
+
if (hookType === "husky") {
|
|
1064
|
+
hookPath = join3(projectPath, ".husky", "pre-commit");
|
|
1065
|
+
} else {
|
|
1066
|
+
hookPath = join3(projectPath, ".git", "hooks", "pre-commit");
|
|
1067
|
+
}
|
|
1068
|
+
mkdirSync(dirname2(hookPath), { recursive: true });
|
|
1069
|
+
writeFileSync(hookPath, hookContent, { mode: 493 });
|
|
1070
|
+
return hookPath;
|
|
1071
|
+
}
|
|
1072
|
+
function shannonEntropy(str) {
|
|
1073
|
+
const freq = /* @__PURE__ */ new Map();
|
|
1074
|
+
for (const ch of str) {
|
|
1075
|
+
freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
1076
|
+
}
|
|
1077
|
+
let entropy = 0;
|
|
1078
|
+
for (const count of freq.values()) {
|
|
1079
|
+
const p = count / str.length;
|
|
1080
|
+
if (p > 0) entropy -= p * Math.log2(p);
|
|
1081
|
+
}
|
|
1082
|
+
return entropy;
|
|
1083
|
+
}
|
|
1084
|
+
function scanContentForHighEntropy(content, filePath, threshold = 5) {
|
|
1085
|
+
const findings = [];
|
|
1086
|
+
const lines = content.split("\n");
|
|
1087
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1088
|
+
const line = lines[i];
|
|
1089
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("#") || line.trim().startsWith("*")) continue;
|
|
1090
|
+
HIGH_ENTROPY_RE.lastIndex = 0;
|
|
1091
|
+
let match;
|
|
1092
|
+
while ((match = HIGH_ENTROPY_RE.exec(line)) !== null) {
|
|
1093
|
+
const value = match[1] || match[2];
|
|
1094
|
+
if (!value || value.length < 40) continue;
|
|
1095
|
+
if (isTemplateOrPlaceholder(value)) continue;
|
|
1096
|
+
if (ENTROPY_SKIP.some((p) => p.test(value))) continue;
|
|
1097
|
+
const entropy = shannonEntropy(value);
|
|
1098
|
+
if (entropy >= threshold) {
|
|
1099
|
+
findings.push({
|
|
1100
|
+
type: "high-entropy",
|
|
1101
|
+
file: filePath,
|
|
1102
|
+
line: i + 1,
|
|
1103
|
+
match: value,
|
|
1104
|
+
redacted: redactSecret(value),
|
|
1105
|
+
severity: entropy >= 5 ? "high" : "medium"
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return deduplicateFindings(findings);
|
|
1111
|
+
}
|
|
1112
|
+
async function auditProject(projectPath, filePaths, options = {}) {
|
|
1113
|
+
const savedConfig = loadAuditConfig(projectPath);
|
|
1114
|
+
const customPatterns = options.customPatterns ?? savedConfig.customPatterns;
|
|
1115
|
+
const entropyThreshold = options.entropyThreshold ?? savedConfig.entropyThreshold;
|
|
1116
|
+
const includePII = options.includePII ?? savedConfig.includePII;
|
|
1117
|
+
const useAllowlist = options.useAllowlist ?? true;
|
|
1118
|
+
const incrementalScan = options.incrementalScan ?? savedConfig.incrementalScan;
|
|
1119
|
+
const severityOverrides = options.severityOverrides ?? savedConfig.severityOverrides;
|
|
1120
|
+
let extraPiiDomains;
|
|
1121
|
+
const allExtraDomains = [...options.piiSafeDomains || [], ...savedConfig.piiSafeDomains];
|
|
1122
|
+
if (allExtraDomains.length > 0) {
|
|
1123
|
+
extraPiiDomains = new Set(allExtraDomains.map((d) => d.toLowerCase()));
|
|
1124
|
+
}
|
|
1125
|
+
let filesToScan = filePaths;
|
|
1126
|
+
let unchangedCount = 0;
|
|
1127
|
+
let newCache = null;
|
|
1128
|
+
if (incrementalScan) {
|
|
1129
|
+
const { changed, unchanged, cache } = getChangedFiles(projectPath, filePaths);
|
|
1130
|
+
newCache = cache;
|
|
1131
|
+
if (changed.length < filePaths.length) {
|
|
1132
|
+
filesToScan = changed;
|
|
1133
|
+
unchangedCount = unchanged.length;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const allFindings = [];
|
|
1137
|
+
const filesWithSecrets = /* @__PURE__ */ new Set();
|
|
1138
|
+
for (const fp of filesToScan) {
|
|
1139
|
+
try {
|
|
1140
|
+
const content = await readFile3(fp, "utf-8");
|
|
1141
|
+
const relPath = relative3(resolve3(projectPath), resolve3(fp));
|
|
1142
|
+
const isTestFile = /\.(test|spec|mock)\.[jt]sx?$/.test(relPath) || relPath.includes("__tests__");
|
|
1143
|
+
const isDtsFile = relPath.endsWith(".d.ts");
|
|
1144
|
+
let findings = scanContentForSecrets(content, relPath, customPatterns, extraPiiDomains);
|
|
1145
|
+
if (!includePII) {
|
|
1146
|
+
findings = findings.filter((f) => f.type !== "pii");
|
|
1147
|
+
}
|
|
1148
|
+
const entropyFindings = isTestFile || isDtsFile ? [] : scanContentForHighEntropy(content, relPath, entropyThreshold);
|
|
1149
|
+
const combined = [...findings, ...entropyFindings];
|
|
1150
|
+
if (combined.length > 0) {
|
|
1151
|
+
filesWithSecrets.add(relPath);
|
|
1152
|
+
allFindings.push(...combined);
|
|
1153
|
+
}
|
|
1154
|
+
} catch {
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
let finalFindings = applySeverityOverrides(allFindings, severityOverrides);
|
|
1158
|
+
let allowedCount = 0;
|
|
1159
|
+
if (useAllowlist) {
|
|
1160
|
+
const { filtered, allowed } = filterByAllowlist(finalFindings, projectPath);
|
|
1161
|
+
finalFindings = filtered;
|
|
1162
|
+
allowedCount = allowed.length;
|
|
1163
|
+
}
|
|
1164
|
+
finalFindings.sort((a, b) => {
|
|
1165
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
1166
|
+
return order[a.severity] - order[b.severity];
|
|
1167
|
+
});
|
|
1168
|
+
if (newCache) {
|
|
1169
|
+
saveHashCache(projectPath, newCache);
|
|
1170
|
+
}
|
|
1171
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
1172
|
+
const byType = {};
|
|
1173
|
+
for (const f of finalFindings) {
|
|
1174
|
+
bySeverity[f.severity]++;
|
|
1175
|
+
byType[f.type] = (byType[f.type] || 0) + 1;
|
|
1176
|
+
}
|
|
1177
|
+
const recommendations = [];
|
|
1178
|
+
if (bySeverity.critical > 0) {
|
|
1179
|
+
recommendations.push("CRITICAL: Rotate all detected credentials immediately. They may already be compromised.");
|
|
1180
|
+
}
|
|
1181
|
+
if (byType["password"] > 0) {
|
|
1182
|
+
recommendations.push("Move passwords to environment variables or a secrets manager (AWS Secrets Manager, Vault, etc.).");
|
|
1183
|
+
}
|
|
1184
|
+
if (byType["api-key"] > 0 || byType["aws-key"] > 0) {
|
|
1185
|
+
recommendations.push("Use environment variables for API keys. Never commit them to source control.");
|
|
1186
|
+
}
|
|
1187
|
+
if (byType["connection-string"] > 0) {
|
|
1188
|
+
recommendations.push("Database connection strings should use environment variables, not hardcoded values.");
|
|
1189
|
+
}
|
|
1190
|
+
if (byType["private-key"] > 0) {
|
|
1191
|
+
recommendations.push("Private keys should NEVER be in source code. Use a key management service.");
|
|
1192
|
+
}
|
|
1193
|
+
if (byType["pii"] > 0) {
|
|
1194
|
+
recommendations.push("PII detected. Review for GDPR/CCPA compliance. Consider data anonymization.");
|
|
1195
|
+
}
|
|
1196
|
+
if (byType["high-entropy"] > 0) {
|
|
1197
|
+
recommendations.push("High-entropy strings detected that may be secrets. Review manually.");
|
|
1198
|
+
}
|
|
1199
|
+
if (finalFindings.length > 0) {
|
|
1200
|
+
recommendations.push("Add a .gitignore entry for .env files if not already present.");
|
|
1201
|
+
recommendations.push("Run `npx cto-ai-cli --audit` regularly or add to CI pipeline.");
|
|
1202
|
+
}
|
|
1203
|
+
if (finalFindings.length === 0) {
|
|
1204
|
+
recommendations.push("No secrets detected. Great job keeping your codebase clean!");
|
|
1205
|
+
}
|
|
1206
|
+
if (allowedCount > 0) {
|
|
1207
|
+
recommendations.push(`${allowedCount} finding(s) skipped via allowlist (.cto/audit/allowlist.json).`);
|
|
1208
|
+
}
|
|
1209
|
+
if (unchangedCount > 0) {
|
|
1210
|
+
recommendations.push(`${unchangedCount} unchanged file(s) skipped (incremental scan).`);
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
findings: finalFindings,
|
|
1214
|
+
summary: {
|
|
1215
|
+
totalFiles: filePaths.length,
|
|
1216
|
+
filesScanned: filesToScan.length,
|
|
1217
|
+
filesWithSecrets: filesWithSecrets.size,
|
|
1218
|
+
totalFindings: finalFindings.length,
|
|
1219
|
+
bySeverity,
|
|
1220
|
+
byType
|
|
1221
|
+
},
|
|
1222
|
+
recommendations
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
var BUILTIN_PATTERNS, _cachedBuiltinPatterns, PII_SAFE_EMAIL_DOMAINS, DEFAULT_AUDIT_CONFIG, HIGH_ENTROPY_RE, ENTROPY_SKIP;
|
|
1226
|
+
var init_secrets = __esm({
|
|
1227
|
+
"src/govern/secrets.ts"() {
|
|
1228
|
+
"use strict";
|
|
1229
|
+
BUILTIN_PATTERNS = [
|
|
1230
|
+
// API Keys
|
|
1231
|
+
{ type: "api-key", source: `(?:api[_-]?key|apikey)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{20,})['"]?`, flags: "gi", severity: "critical", description: "API Key" },
|
|
1232
|
+
{ type: "api-key", source: "sk-[a-zA-Z0-9]{20,}", flags: "g", severity: "critical", description: "OpenAI/Anthropic API Key" },
|
|
1233
|
+
{ type: "api-key", source: "sk-ant-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "Anthropic API Key" },
|
|
1234
|
+
// AWS
|
|
1235
|
+
{ type: "aws-key", source: "AKIA[0-9A-Z]{16}", flags: "g", severity: "critical", description: "AWS Access Key ID" },
|
|
1236
|
+
{ 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" },
|
|
1237
|
+
// Private Keys
|
|
1238
|
+
{ type: "private-key", source: "-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", flags: "g", severity: "critical", description: "Private Key" },
|
|
1239
|
+
{ type: "private-key", source: "-----BEGIN OPENSSH PRIVATE KEY-----", flags: "g", severity: "critical", description: "SSH Private Key" },
|
|
1240
|
+
// Passwords
|
|
1241
|
+
{ type: "password", source: `(?:password|passwd|pwd)\\s*[:=]\\s*['"]([^'"]{8,})['"](?!\\s*\\{)`, flags: "gi", severity: "high", description: "Hardcoded Password" },
|
|
1242
|
+
{ type: "password", source: `(?:DB_PASSWORD|DATABASE_PASSWORD|MYSQL_PASSWORD|POSTGRES_PASSWORD)\\s*[:=]\\s*['"]?([^'"{}\\s]{4,})['"]?`, flags: "gi", severity: "high", description: "Database Password" },
|
|
1243
|
+
// Tokens
|
|
1244
|
+
{ 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" },
|
|
1245
|
+
{ type: "token", source: "ghp_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub Personal Access Token" },
|
|
1246
|
+
{ type: "token", source: "gho_[a-zA-Z0-9]{36}", flags: "g", severity: "critical", description: "GitHub OAuth Token" },
|
|
1247
|
+
{ type: "token", source: "glpat-[a-zA-Z0-9\\-]{20,}", flags: "g", severity: "critical", description: "GitLab Personal Access Token" },
|
|
1248
|
+
{ type: "token", source: "npm_[a-zA-Z0-9]{36}", flags: "g", severity: "high", description: "npm Token" },
|
|
1249
|
+
// Connection strings
|
|
1250
|
+
{ type: "connection-string", source: `(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\\/\\/[^\\s'"]+:[^\\s'"]+@[^\\s'"]+`, flags: "gi", severity: "critical", description: "Database Connection String" },
|
|
1251
|
+
{ type: "connection-string", source: `(?:DATABASE_URL|REDIS_URL|MONGODB_URI)\\s*[:=]\\s*['"]?([^\\s'"]{10,})['"]?`, flags: "gi", severity: "high", description: "Database URL" },
|
|
1252
|
+
// Environment variables with secrets
|
|
1253
|
+
{ type: "env-variable", source: `(?:SECRET|PRIVATE|ENCRYPTION)[_-]?(?:KEY|TOKEN|PASS)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, flags: "gi", severity: "high", description: "Secret Environment Variable" },
|
|
1254
|
+
// Stripe
|
|
1255
|
+
{ type: "api-key", source: "sk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Stripe Live Secret Key" },
|
|
1256
|
+
{ type: "api-key", source: "pk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "high", description: "Stripe Live Publishable Key" },
|
|
1257
|
+
{ type: "api-key", source: "rk_live_[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Stripe Restricted Key" },
|
|
1258
|
+
// Slack
|
|
1259
|
+
{ type: "token", source: "xoxb-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Slack Bot Token" },
|
|
1260
|
+
{ type: "token", source: "xoxp-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24,}", flags: "g", severity: "critical", description: "Slack User Token" },
|
|
1261
|
+
{ type: "api-key", source: "https://hooks\\.slack\\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+", flags: "g", severity: "high", description: "Slack Webhook URL" },
|
|
1262
|
+
// Google
|
|
1263
|
+
{ type: "api-key", source: "AIza[0-9A-Za-z_-]{35}", flags: "g", severity: "high", description: "Google API Key" },
|
|
1264
|
+
{ type: "token", source: "ya29\\.[0-9A-Za-z_-]+", flags: "g", severity: "high", description: "Google OAuth Token" },
|
|
1265
|
+
// Azure
|
|
1266
|
+
{ type: "api-key", source: "(?:AccountKey|SharedAccessKey)\\s*=\\s*[a-zA-Z0-9+/=]{40,}", flags: "g", severity: "critical", description: "Azure Storage Key" },
|
|
1267
|
+
// Twilio
|
|
1268
|
+
{ type: "api-key", source: "AC[a-f0-9]{32}", flags: "g", severity: "high", description: "Twilio Account SID" },
|
|
1269
|
+
// SendGrid
|
|
1270
|
+
{ type: "api-key", source: "SG\\.[a-zA-Z0-9_-]{22}\\.[a-zA-Z0-9_-]{43}", flags: "g", severity: "critical", description: "SendGrid API Key" },
|
|
1271
|
+
// JWT
|
|
1272
|
+
{ type: "token", source: "eyJ[a-zA-Z0-9_-]{10,}\\.eyJ[a-zA-Z0-9_-]{10,}\\.[a-zA-Z0-9_-]{10,}", flags: "g", severity: "high", description: "JSON Web Token" },
|
|
1273
|
+
// Datadog
|
|
1274
|
+
{ type: "api-key", source: `(?:DD_API_KEY|DATADOG_API_KEY)\\s*[:=]\\s*['"]?([a-f0-9]{32})['"]?`, flags: "gi", severity: "critical", description: "Datadog API Key" },
|
|
1275
|
+
{ type: "api-key", source: `(?:DD_APP_KEY|DATADOG_APP_KEY)\\s*[:=]\\s*['"]?([a-f0-9]{40})['"]?`, flags: "gi", severity: "critical", description: "Datadog App Key" },
|
|
1276
|
+
// Sentry
|
|
1277
|
+
{ type: "connection-string", source: "https://[a-f0-9]{32}@[a-z0-9]+\\.ingest\\.sentry\\.io/[0-9]+", flags: "g", severity: "high", description: "Sentry DSN" },
|
|
1278
|
+
// Firebase
|
|
1279
|
+
{ type: "api-key", source: `(?:FIREBASE_API_KEY|FIREBASE_KEY)\\s*[:=]\\s*['"]?([a-zA-Z0-9_\\-]{30,})['"]?`, flags: "gi", severity: "high", description: "Firebase API Key" },
|
|
1280
|
+
{ type: "connection-string", source: `firebase[a-z]*:\\/\\/[^\\s'"]+`, flags: "gi", severity: "high", description: "Firebase URL" },
|
|
1281
|
+
// Supabase
|
|
1282
|
+
{ type: "api-key", source: "sbp_[a-f0-9]{40}", flags: "g", severity: "critical", description: "Supabase Service Key" },
|
|
1283
|
+
{ type: "token", source: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\\.[a-zA-Z0-9_-]{20,}\\.[a-zA-Z0-9_-]{20,}", flags: "g", severity: "high", description: "Supabase Anon/Service JWT" },
|
|
1284
|
+
// Vercel
|
|
1285
|
+
{ type: "token", source: `(?:VERCEL_TOKEN|VERCEL_API_TOKEN)\\s*[:=]\\s*['"]?([a-zA-Z0-9]{24,})['"]?`, flags: "gi", severity: "critical", description: "Vercel Token" },
|
|
1286
|
+
// Heroku
|
|
1287
|
+
{ type: "api-key", source: `(?:HEROKU_API_KEY|HEROKU_TOKEN)\\s*[:=]\\s*['"]?([a-f0-9\\-]{36,})['"]?`, flags: "gi", severity: "critical", description: "Heroku API Key" },
|
|
1288
|
+
// DigitalOcean
|
|
1289
|
+
{ type: "token", source: "dop_v1_[a-f0-9]{64}", flags: "g", severity: "critical", description: "DigitalOcean Personal Access Token" },
|
|
1290
|
+
{ type: "token", source: "doo_v1_[a-f0-9]{64}", flags: "g", severity: "critical", description: "DigitalOcean OAuth Token" },
|
|
1291
|
+
// Mailgun
|
|
1292
|
+
{ type: "api-key", source: "key-[a-zA-Z0-9]{32}", flags: "g", severity: "high", description: "Mailgun API Key" },
|
|
1293
|
+
// PII
|
|
1294
|
+
{ type: "pii", source: "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b", flags: "g", severity: "medium", description: "Email Address (PII)" },
|
|
1295
|
+
{ type: "pii", source: "\\b(?!000|666|9\\d{2})(\\d{3})[-.]?(?!00)(\\d{2})[-.]?(?!0000)(\\d{4})\\b", flags: "g", severity: "high", description: "Possible SSN (PII)" }
|
|
1296
|
+
];
|
|
1297
|
+
_cachedBuiltinPatterns = null;
|
|
1298
|
+
PII_SAFE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
|
|
1299
|
+
"example.com",
|
|
1300
|
+
"example.org",
|
|
1301
|
+
"example.net",
|
|
1302
|
+
"test.com",
|
|
1303
|
+
"test.org",
|
|
1304
|
+
"test.net",
|
|
1305
|
+
"localhost",
|
|
1306
|
+
"localhost.localdomain",
|
|
1307
|
+
"email.com",
|
|
1308
|
+
"mail.com",
|
|
1309
|
+
"foo.com",
|
|
1310
|
+
"bar.com",
|
|
1311
|
+
"baz.com",
|
|
1312
|
+
"acme.com",
|
|
1313
|
+
"company.com",
|
|
1314
|
+
"corp.com",
|
|
1315
|
+
"noreply.com",
|
|
1316
|
+
"no-reply.com",
|
|
1317
|
+
"users.noreply.github.com",
|
|
1318
|
+
"placeholder.com"
|
|
1319
|
+
]);
|
|
1320
|
+
DEFAULT_AUDIT_CONFIG = {
|
|
1321
|
+
severityOverrides: {},
|
|
1322
|
+
piiSafeDomains: [],
|
|
1323
|
+
customPatterns: [],
|
|
1324
|
+
entropyThreshold: 5,
|
|
1325
|
+
includePII: true,
|
|
1326
|
+
incrementalScan: true
|
|
1327
|
+
};
|
|
1328
|
+
HIGH_ENTROPY_RE = /['"]([a-zA-Z0-9+/=_\-]{30,})['"]|=\s*['"]?([a-zA-Z0-9+/=_\-]{30,})['"]?/g;
|
|
1329
|
+
ENTROPY_SKIP = [
|
|
1330
|
+
/^[a-f0-9]{32,}$/i,
|
|
1331
|
+
// hex hashes
|
|
1332
|
+
/^[A-Z_]{30,}$/,
|
|
1333
|
+
// all-caps constants
|
|
1334
|
+
/^[a-z_]{30,}$/,
|
|
1335
|
+
// all-lowercase identifiers
|
|
1336
|
+
/^[a-zA-Z0-9+/]+=+$/,
|
|
1337
|
+
// base64 padding
|
|
1338
|
+
/^[a-z]+[A-Z][a-zA-Z]+$/,
|
|
1339
|
+
// camelCase identifiers
|
|
1340
|
+
/sha\d+-/i
|
|
1341
|
+
// integrity hashes (sha256-, sha512-)
|
|
1342
|
+
];
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
830
1345
|
|
|
831
1346
|
// src/engine/pruner.ts
|
|
832
1347
|
import { Project as Project2, SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
833
1348
|
import { readFile as readFile4 } from "fs/promises";
|
|
834
|
-
import { existsSync as
|
|
835
|
-
import { join as
|
|
836
|
-
var TS_EXTENSIONS2 = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs"]);
|
|
1349
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1350
|
+
import { join as join4 } from "path";
|
|
837
1351
|
async function pruneFile(file, level) {
|
|
838
1352
|
if (level === "excluded") {
|
|
839
1353
|
return emptyResult(file, "excluded");
|
|
@@ -1078,12 +1592,20 @@ function addJSDoc(node, parts) {
|
|
|
1078
1592
|
function findTsConfig(filePath) {
|
|
1079
1593
|
let dir = filePath;
|
|
1080
1594
|
for (let i = 0; i < 10; i++) {
|
|
1081
|
-
dir =
|
|
1082
|
-
const candidate =
|
|
1083
|
-
if (
|
|
1595
|
+
dir = join4(dir, "..");
|
|
1596
|
+
const candidate = join4(dir, "tsconfig.json");
|
|
1597
|
+
if (existsSync3(candidate)) return candidate;
|
|
1084
1598
|
}
|
|
1085
1599
|
return void 0;
|
|
1086
1600
|
}
|
|
1601
|
+
var TS_EXTENSIONS2;
|
|
1602
|
+
var init_pruner = __esm({
|
|
1603
|
+
"src/engine/pruner.ts"() {
|
|
1604
|
+
"use strict";
|
|
1605
|
+
init_tokenizer();
|
|
1606
|
+
TS_EXTENSIONS2 = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mts", "mjs"]);
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1087
1609
|
|
|
1088
1610
|
// src/engine/graph-utils.ts
|
|
1089
1611
|
function buildAdjacencyList(edges) {
|
|
@@ -1137,6 +1659,11 @@ function matchGlob(path, pattern) {
|
|
|
1137
1659
|
return false;
|
|
1138
1660
|
}
|
|
1139
1661
|
}
|
|
1662
|
+
var init_graph_utils = __esm({
|
|
1663
|
+
"src/engine/graph-utils.ts"() {
|
|
1664
|
+
"use strict";
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1140
1667
|
|
|
1141
1668
|
// src/engine/coverage.ts
|
|
1142
1669
|
function calculateCoverage(targetPaths, includedPaths, allFiles, graph, depth = 2) {
|
|
@@ -1197,6 +1724,12 @@ function calculateCoverage(targetPaths, includedPaths, allFiles, graph, depth =
|
|
|
1197
1724
|
explanation
|
|
1198
1725
|
};
|
|
1199
1726
|
}
|
|
1727
|
+
var init_coverage = __esm({
|
|
1728
|
+
"src/engine/coverage.ts"() {
|
|
1729
|
+
"use strict";
|
|
1730
|
+
init_graph_utils();
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1200
1733
|
|
|
1201
1734
|
// src/engine/budget.ts
|
|
1202
1735
|
function getPruneLevelForRisk(riskScore) {
|
|
@@ -1205,8 +1738,15 @@ function getPruneLevelForRisk(riskScore) {
|
|
|
1205
1738
|
if (riskScore >= 30) return "signatures";
|
|
1206
1739
|
return "skeleton";
|
|
1207
1740
|
}
|
|
1741
|
+
var init_budget = __esm({
|
|
1742
|
+
"src/engine/budget.ts"() {
|
|
1743
|
+
"use strict";
|
|
1744
|
+
init_pruner();
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1208
1747
|
|
|
1209
1748
|
// src/engine/selector.ts
|
|
1749
|
+
import { createHash as createHash3 } from "crypto";
|
|
1210
1750
|
async function selectContext(input) {
|
|
1211
1751
|
const { task, analysis, budget, policies, depth = 2 } = input;
|
|
1212
1752
|
const decisions = [];
|
|
@@ -1347,7 +1887,7 @@ async function selectContext(input) {
|
|
|
1347
1887
|
);
|
|
1348
1888
|
const excludedRisk = excludedFiles.length > 0 ? Math.round(excludedFiles.reduce((s, f) => s + f.riskScore, 0) / excludedFiles.length) : 0;
|
|
1349
1889
|
const hashInput = selectedFiles.map((f) => `${f.relativePath}:${f.pruneLevel}`).sort().join("|") + `|budget:${budget}`;
|
|
1350
|
-
const hash =
|
|
1890
|
+
const hash = createHash3("sha256").update(hashInput).digest("hex").substring(0, 16);
|
|
1351
1891
|
return {
|
|
1352
1892
|
files: selectedFiles,
|
|
1353
1893
|
totalTokens: usedTokens,
|
|
@@ -1413,94 +1953,1443 @@ function buildReason(file, level, isTarget, isMustInclude) {
|
|
|
1413
1953
|
if (impact === "medium") return `Medium relevance (risk ${file.riskScore}) \u2014 ${levelStr}`;
|
|
1414
1954
|
return `Low relevance (risk ${file.riskScore}) \u2014 ${levelStr}`;
|
|
1415
1955
|
}
|
|
1956
|
+
var init_selector = __esm({
|
|
1957
|
+
"src/engine/selector.ts"() {
|
|
1958
|
+
"use strict";
|
|
1959
|
+
init_secrets();
|
|
1960
|
+
init_pruner();
|
|
1961
|
+
init_coverage();
|
|
1962
|
+
init_budget();
|
|
1963
|
+
init_graph_utils();
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1416
1966
|
|
|
1417
|
-
// src/
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1967
|
+
// src/gateway/types.ts
|
|
1968
|
+
var types_exports = {};
|
|
1969
|
+
__export(types_exports, {
|
|
1970
|
+
DEFAULT_ALLOWED_DOMAINS: () => DEFAULT_ALLOWED_DOMAINS,
|
|
1971
|
+
DEFAULT_GATEWAY_CONFIG: () => DEFAULT_GATEWAY_CONFIG,
|
|
1972
|
+
isAllowedTarget: () => isAllowedTarget,
|
|
1973
|
+
isPrivateIP: () => isPrivateIP
|
|
1974
|
+
});
|
|
1975
|
+
function isPrivateIP(ip) {
|
|
1976
|
+
return PRIVATE_IP_PATTERNS.some((p) => p.test(ip));
|
|
1977
|
+
}
|
|
1978
|
+
function isAllowedTarget(hostname, config) {
|
|
1979
|
+
if (config.allowedTargetDomains.length > 0) {
|
|
1980
|
+
return config.allowedTargetDomains.some(
|
|
1981
|
+
(d) => hostname === d || hostname.endsWith("." + d)
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
if (DEFAULT_ALLOWED_DOMAINS.has(hostname)) return true;
|
|
1985
|
+
if (hostname.endsWith(".openai.azure.com")) return true;
|
|
1986
|
+
return false;
|
|
1987
|
+
}
|
|
1988
|
+
var DEFAULT_GATEWAY_CONFIG, DEFAULT_ALLOWED_DOMAINS, PRIVATE_IP_PATTERNS;
|
|
1989
|
+
var init_types = __esm({
|
|
1990
|
+
"src/gateway/types.ts"() {
|
|
1991
|
+
"use strict";
|
|
1992
|
+
DEFAULT_GATEWAY_CONFIG = {
|
|
1993
|
+
port: 8787,
|
|
1994
|
+
host: "127.0.0.1",
|
|
1995
|
+
optimize: true,
|
|
1996
|
+
projectPath: ".",
|
|
1997
|
+
budget: 5e4,
|
|
1998
|
+
redactSecrets: true,
|
|
1999
|
+
blockOnSecrets: false,
|
|
2000
|
+
apiKey: "",
|
|
2001
|
+
allowedTargetDomains: [],
|
|
2002
|
+
// Empty = default LLM provider allowlist
|
|
2003
|
+
maxBodyBytes: 10 * 1024 * 1024,
|
|
2004
|
+
// 10MB
|
|
2005
|
+
upstreamTimeoutMs: 12e4,
|
|
2006
|
+
// 2 minutes (streaming can be slow)
|
|
2007
|
+
costTracking: true,
|
|
2008
|
+
budgetDaily: 0,
|
|
2009
|
+
budgetMonthly: 0,
|
|
2010
|
+
alertThreshold: 0.8,
|
|
2011
|
+
auditLog: true,
|
|
2012
|
+
logDir: ".cto/gateway",
|
|
2013
|
+
dashboard: true,
|
|
2014
|
+
dashboardPath: "/__cto"
|
|
2015
|
+
};
|
|
2016
|
+
DEFAULT_ALLOWED_DOMAINS = /* @__PURE__ */ new Set([
|
|
2017
|
+
"api.openai.com",
|
|
2018
|
+
"api.anthropic.com",
|
|
2019
|
+
"generativelanguage.googleapis.com",
|
|
2020
|
+
"aiplatform.googleapis.com"
|
|
2021
|
+
// Azure uses custom subdomains: *.openai.azure.com
|
|
2022
|
+
]);
|
|
2023
|
+
PRIVATE_IP_PATTERNS = [
|
|
2024
|
+
/^127\./,
|
|
2025
|
+
// Loopback
|
|
2026
|
+
/^10\./,
|
|
2027
|
+
// Class A private
|
|
2028
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
2029
|
+
// Class B private
|
|
2030
|
+
/^192\.168\./,
|
|
2031
|
+
// Class C private
|
|
2032
|
+
/^169\.254\./,
|
|
2033
|
+
// Link-local (AWS metadata!)
|
|
2034
|
+
/^0\./,
|
|
2035
|
+
// Current network
|
|
2036
|
+
/^::1$/,
|
|
2037
|
+
// IPv6 loopback
|
|
2038
|
+
/^f[cd]/i,
|
|
2039
|
+
// IPv6 private
|
|
2040
|
+
/^fe80:/i
|
|
2041
|
+
// IPv6 link-local
|
|
2042
|
+
];
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
// src/gateway/providers.ts
|
|
2047
|
+
function parseOpenAIRequest(body) {
|
|
2048
|
+
const messages = (body.messages || []).map((m) => ({
|
|
2049
|
+
role: m.role || "user",
|
|
2050
|
+
content: typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
|
2051
|
+
}));
|
|
1439
2052
|
return {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
riskControl,
|
|
1446
|
-
structure,
|
|
1447
|
-
governance
|
|
1448
|
-
},
|
|
1449
|
-
insights: insights.sort((a, b) => {
|
|
1450
|
-
const order = { high: 0, medium: 1, low: 2 };
|
|
1451
|
-
return order[a.impact] - order[b.impact];
|
|
1452
|
-
}),
|
|
1453
|
-
comparison: {
|
|
1454
|
-
naiveTokens,
|
|
1455
|
-
optimizedTokens,
|
|
1456
|
-
savedTokens,
|
|
1457
|
-
savedPercent,
|
|
1458
|
-
monthlySavingsUSD
|
|
1459
|
-
},
|
|
1460
|
-
meta: {
|
|
1461
|
-
projectName: analysis.projectName,
|
|
1462
|
-
totalFiles: analysis.totalFiles,
|
|
1463
|
-
totalTokens: analysis.totalTokens,
|
|
1464
|
-
analyzedAt: analysis.analyzedAt
|
|
1465
|
-
}
|
|
2053
|
+
model: body.model || "unknown",
|
|
2054
|
+
messages,
|
|
2055
|
+
stream: body.stream === true,
|
|
2056
|
+
maxTokens: body.max_tokens ?? body.max_completion_tokens,
|
|
2057
|
+
temperature: body.temperature
|
|
1466
2058
|
};
|
|
1467
2059
|
}
|
|
1468
|
-
function
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
const score = Math.min(100, Math.max(0, Math.round(raw)));
|
|
1478
|
-
const weighted = score / 100 * weight;
|
|
1479
|
-
if (ratio > 0.7) {
|
|
1480
|
-
insights.push({
|
|
1481
|
-
type: "strength",
|
|
1482
|
-
title: "Excellent compression",
|
|
1483
|
-
detail: `${Math.round(ratio * 100)}% token reduction while maintaining context quality`,
|
|
1484
|
-
impact: "high"
|
|
1485
|
-
});
|
|
2060
|
+
function parseOpenAIResponse(body, streaming) {
|
|
2061
|
+
if (streaming) {
|
|
2062
|
+
return {
|
|
2063
|
+
model: body.model || "unknown",
|
|
2064
|
+
inputTokens: body.usage?.prompt_tokens || 0,
|
|
2065
|
+
outputTokens: body.usage?.completion_tokens || 0,
|
|
2066
|
+
content: body.choices?.[0]?.message?.content || "",
|
|
2067
|
+
finishReason: body.choices?.[0]?.finish_reason || "stop"
|
|
2068
|
+
};
|
|
1486
2069
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
2070
|
+
return {
|
|
2071
|
+
model: body.model || "unknown",
|
|
2072
|
+
inputTokens: body.usage?.prompt_tokens || 0,
|
|
2073
|
+
outputTokens: body.usage?.completion_tokens || 0,
|
|
2074
|
+
content: body.choices?.[0]?.message?.content || "",
|
|
2075
|
+
finishReason: body.choices?.[0]?.finish_reason || "stop"
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
function parseAnthropicRequest(body) {
|
|
2079
|
+
const messages = [];
|
|
2080
|
+
if (body.system) {
|
|
2081
|
+
messages.push({ role: "system", content: body.system });
|
|
2082
|
+
}
|
|
2083
|
+
for (const m of body.messages || []) {
|
|
2084
|
+
messages.push({
|
|
2085
|
+
role: m.role || "user",
|
|
2086
|
+
content: typeof m.content === "string" ? m.content : m.content?.map((b) => b.text || "").join("\n") || ""
|
|
1493
2087
|
});
|
|
1494
2088
|
}
|
|
1495
2089
|
return {
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
2090
|
+
model: body.model || "unknown",
|
|
2091
|
+
messages,
|
|
2092
|
+
stream: body.stream === true,
|
|
2093
|
+
maxTokens: body.max_tokens,
|
|
2094
|
+
temperature: body.temperature
|
|
1500
2095
|
};
|
|
1501
2096
|
}
|
|
1502
|
-
function
|
|
1503
|
-
|
|
2097
|
+
function parseAnthropicResponse(body, _streaming) {
|
|
2098
|
+
return {
|
|
2099
|
+
model: body.model || "unknown",
|
|
2100
|
+
inputTokens: body.usage?.input_tokens || 0,
|
|
2101
|
+
outputTokens: body.usage?.output_tokens || 0,
|
|
2102
|
+
content: body.content?.map((b) => b.text || "").join("\n") || "",
|
|
2103
|
+
finishReason: body.stop_reason || "end_turn"
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
function parseGoogleRequest(body) {
|
|
2107
|
+
const messages = [];
|
|
2108
|
+
if (body.systemInstruction?.parts) {
|
|
2109
|
+
messages.push({
|
|
2110
|
+
role: "system",
|
|
2111
|
+
content: body.systemInstruction.parts.map((p) => p.text || "").join("\n")
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
for (const item of body.contents || []) {
|
|
2115
|
+
const role = item.role === "model" ? "assistant" : "user";
|
|
2116
|
+
const content = item.parts?.map((p) => p.text || "").join("\n") || "";
|
|
2117
|
+
messages.push({ role, content });
|
|
2118
|
+
}
|
|
2119
|
+
const model = body.model || body.modelId || "gemini-2.0-flash";
|
|
2120
|
+
return {
|
|
2121
|
+
model,
|
|
2122
|
+
messages,
|
|
2123
|
+
stream: body.stream === true,
|
|
2124
|
+
maxTokens: body.generationConfig?.maxOutputTokens,
|
|
2125
|
+
temperature: body.generationConfig?.temperature
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
function parseGoogleResponse(body, _streaming) {
|
|
2129
|
+
const candidate = body.candidates?.[0];
|
|
2130
|
+
return {
|
|
2131
|
+
model: body.modelVersion || body.model || "gemini-2.0-flash",
|
|
2132
|
+
inputTokens: body.usageMetadata?.promptTokenCount || 0,
|
|
2133
|
+
outputTokens: body.usageMetadata?.candidatesTokenCount || 0,
|
|
2134
|
+
content: candidate?.content?.parts?.map((p) => p.text || "").join("\n") || "",
|
|
2135
|
+
finishReason: candidate?.finishReason || "STOP"
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
function detectProvider(url, headers) {
|
|
2139
|
+
for (const provider of Object.values(PROVIDERS)) {
|
|
2140
|
+
if (provider.name === "custom") continue;
|
|
2141
|
+
if (provider.detectProvider(url, headers)) return provider;
|
|
2142
|
+
}
|
|
2143
|
+
return PROVIDERS.custom;
|
|
2144
|
+
}
|
|
2145
|
+
function getModelConfig(provider, modelId) {
|
|
2146
|
+
const exact = provider.models.find((m) => m.id === modelId);
|
|
2147
|
+
if (exact) return exact;
|
|
2148
|
+
return provider.models.find((m) => modelId.startsWith(m.id) || m.id.startsWith(modelId));
|
|
2149
|
+
}
|
|
2150
|
+
function estimateCost(provider, modelId, inputTokens, outputTokens) {
|
|
2151
|
+
const model = getModelConfig(provider, modelId);
|
|
2152
|
+
if (!model) return 0;
|
|
2153
|
+
const inputCost = inputTokens / 1e6 * model.costPerMInput;
|
|
2154
|
+
const outputCost = outputTokens / 1e6 * model.costPerMOutput;
|
|
2155
|
+
return Math.round((inputCost + outputCost) * 1e6) / 1e6;
|
|
2156
|
+
}
|
|
2157
|
+
var OPENAI_MODELS, ANTHROPIC_MODELS, GOOGLE_MODELS, PROVIDERS;
|
|
2158
|
+
var init_providers = __esm({
|
|
2159
|
+
"src/gateway/providers.ts"() {
|
|
2160
|
+
"use strict";
|
|
2161
|
+
OPENAI_MODELS = [
|
|
2162
|
+
{ id: "gpt-4o", contextWindow: 128e3, costPerMInput: 2.5, costPerMOutput: 10, maxOutput: 16384 },
|
|
2163
|
+
{ id: "gpt-4o-mini", contextWindow: 128e3, costPerMInput: 0.15, costPerMOutput: 0.6, maxOutput: 16384 },
|
|
2164
|
+
{ id: "gpt-4-turbo", contextWindow: 128e3, costPerMInput: 10, costPerMOutput: 30, maxOutput: 4096 },
|
|
2165
|
+
{ id: "gpt-3.5-turbo", contextWindow: 16385, costPerMInput: 0.5, costPerMOutput: 1.5, maxOutput: 4096 },
|
|
2166
|
+
{ id: "o1", contextWindow: 2e5, costPerMInput: 15, costPerMOutput: 60, maxOutput: 1e5 },
|
|
2167
|
+
{ id: "o1-mini", contextWindow: 128e3, costPerMInput: 3, costPerMOutput: 12, maxOutput: 65536 },
|
|
2168
|
+
{ id: "o3-mini", contextWindow: 2e5, costPerMInput: 1.1, costPerMOutput: 4.4, maxOutput: 1e5 }
|
|
2169
|
+
];
|
|
2170
|
+
ANTHROPIC_MODELS = [
|
|
2171
|
+
{ id: "claude-sonnet-4-20250514", contextWindow: 2e5, costPerMInput: 3, costPerMOutput: 15, maxOutput: 64e3 },
|
|
2172
|
+
{ id: "claude-3-5-haiku-20241022", contextWindow: 2e5, costPerMInput: 0.8, costPerMOutput: 4, maxOutput: 8192 },
|
|
2173
|
+
{ id: "claude-3-opus-20240229", contextWindow: 2e5, costPerMInput: 15, costPerMOutput: 75, maxOutput: 4096 }
|
|
2174
|
+
];
|
|
2175
|
+
GOOGLE_MODELS = [
|
|
2176
|
+
{ id: "gemini-2.5-pro", contextWindow: 1e6, costPerMInput: 1.25, costPerMOutput: 10, maxOutput: 65536 },
|
|
2177
|
+
{ id: "gemini-2.0-flash", contextWindow: 1e6, costPerMInput: 0.1, costPerMOutput: 0.4, maxOutput: 8192 },
|
|
2178
|
+
{ id: "gemini-1.5-pro", contextWindow: 2e6, costPerMInput: 1.25, costPerMOutput: 5, maxOutput: 8192 }
|
|
2179
|
+
];
|
|
2180
|
+
PROVIDERS = {
|
|
2181
|
+
openai: {
|
|
2182
|
+
name: "openai",
|
|
2183
|
+
displayName: "OpenAI",
|
|
2184
|
+
baseUrl: "https://api.openai.com",
|
|
2185
|
+
authHeader: "Authorization",
|
|
2186
|
+
chatPath: "/v1/chat/completions",
|
|
2187
|
+
models: OPENAI_MODELS,
|
|
2188
|
+
parseRequest: parseOpenAIRequest,
|
|
2189
|
+
parseResponse: parseOpenAIResponse,
|
|
2190
|
+
detectProvider: (url, _headers) => url.includes("api.openai.com") || url.includes("/v1/chat/completions")
|
|
2191
|
+
},
|
|
2192
|
+
anthropic: {
|
|
2193
|
+
name: "anthropic",
|
|
2194
|
+
displayName: "Anthropic",
|
|
2195
|
+
baseUrl: "https://api.anthropic.com",
|
|
2196
|
+
authHeader: "x-api-key",
|
|
2197
|
+
chatPath: "/v1/messages",
|
|
2198
|
+
models: ANTHROPIC_MODELS,
|
|
2199
|
+
parseRequest: parseAnthropicRequest,
|
|
2200
|
+
parseResponse: parseAnthropicResponse,
|
|
2201
|
+
detectProvider: (url, headers) => url.includes("api.anthropic.com") || url.includes("/v1/messages") || !!headers["x-api-key"] || !!headers["anthropic-version"]
|
|
2202
|
+
},
|
|
2203
|
+
google: {
|
|
2204
|
+
name: "google",
|
|
2205
|
+
displayName: "Google AI",
|
|
2206
|
+
baseUrl: "https://generativelanguage.googleapis.com",
|
|
2207
|
+
authHeader: "x-goog-api-key",
|
|
2208
|
+
chatPath: "/v1beta/models",
|
|
2209
|
+
models: GOOGLE_MODELS,
|
|
2210
|
+
parseRequest: parseGoogleRequest,
|
|
2211
|
+
parseResponse: parseGoogleResponse,
|
|
2212
|
+
detectProvider: (url, _headers) => url.includes("generativelanguage.googleapis.com") || url.includes("aiplatform.googleapis.com")
|
|
2213
|
+
},
|
|
2214
|
+
"azure-openai": {
|
|
2215
|
+
name: "azure-openai",
|
|
2216
|
+
displayName: "Azure OpenAI",
|
|
2217
|
+
baseUrl: "",
|
|
2218
|
+
authHeader: "api-key",
|
|
2219
|
+
chatPath: "/openai/deployments",
|
|
2220
|
+
models: OPENAI_MODELS,
|
|
2221
|
+
// Same models, different hosting
|
|
2222
|
+
parseRequest: parseOpenAIRequest,
|
|
2223
|
+
parseResponse: parseOpenAIResponse,
|
|
2224
|
+
detectProvider: (url, headers) => url.includes(".openai.azure.com") || !!headers["api-key"]
|
|
2225
|
+
},
|
|
2226
|
+
custom: {
|
|
2227
|
+
name: "custom",
|
|
2228
|
+
displayName: "Custom (OpenAI-compatible)",
|
|
2229
|
+
baseUrl: "",
|
|
2230
|
+
authHeader: "Authorization",
|
|
2231
|
+
chatPath: "/v1/chat/completions",
|
|
2232
|
+
models: [],
|
|
2233
|
+
parseRequest: parseOpenAIRequest,
|
|
2234
|
+
parseResponse: parseOpenAIResponse,
|
|
2235
|
+
detectProvider: () => false
|
|
2236
|
+
// Fallback only
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
// src/gateway/interceptor.ts
|
|
2243
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
2244
|
+
import { resolve as resolve6 } from "path";
|
|
2245
|
+
function estimateTokensFromString(s) {
|
|
2246
|
+
return Math.ceil(Buffer.byteLength(s, "utf-8") / 4);
|
|
2247
|
+
}
|
|
2248
|
+
async function interceptRequest(messages, config, analysis) {
|
|
2249
|
+
const decisions = [];
|
|
2250
|
+
let secretsRedacted = 0;
|
|
2251
|
+
let secretsBlocked = false;
|
|
2252
|
+
let contextInjected = false;
|
|
2253
|
+
const originalTokens = messages.reduce((sum, m) => sum + estimateTokensFromString(m.content), 0);
|
|
2254
|
+
let processedMessages = messages;
|
|
2255
|
+
if (config.redactSecrets || config.blockOnSecrets) {
|
|
2256
|
+
const { messages: scannedMessages, redactedCount, blocked, scanDecisions } = scanMessages(messages, config);
|
|
2257
|
+
processedMessages = scannedMessages;
|
|
2258
|
+
secretsRedacted = redactedCount;
|
|
2259
|
+
secretsBlocked = blocked;
|
|
2260
|
+
decisions.push(...scanDecisions);
|
|
2261
|
+
if (blocked) {
|
|
2262
|
+
return {
|
|
2263
|
+
modified: true,
|
|
2264
|
+
messages: processedMessages,
|
|
2265
|
+
originalTokens,
|
|
2266
|
+
optimizedTokens: 0,
|
|
2267
|
+
secretsRedacted,
|
|
2268
|
+
secretsBlocked: true,
|
|
2269
|
+
contextInjected: false,
|
|
2270
|
+
decisions
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
if (config.optimize && analysis) {
|
|
2275
|
+
const { messages: optimizedMessages, injected, optimizeDecisions } = await optimizeContext(processedMessages, analysis, config);
|
|
2276
|
+
processedMessages = optimizedMessages;
|
|
2277
|
+
contextInjected = injected;
|
|
2278
|
+
decisions.push(...optimizeDecisions);
|
|
2279
|
+
}
|
|
2280
|
+
const optimizedTokens = processedMessages.reduce((sum, m) => sum + estimateTokensFromString(m.content), 0);
|
|
2281
|
+
return {
|
|
2282
|
+
modified: secretsRedacted > 0 || contextInjected,
|
|
2283
|
+
messages: processedMessages,
|
|
2284
|
+
originalTokens,
|
|
2285
|
+
optimizedTokens,
|
|
2286
|
+
secretsRedacted,
|
|
2287
|
+
secretsBlocked,
|
|
2288
|
+
contextInjected,
|
|
2289
|
+
decisions
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
function scanMessages(messages, config) {
|
|
2293
|
+
const scanDecisions = [];
|
|
2294
|
+
let redactedCount = 0;
|
|
2295
|
+
let blocked = false;
|
|
2296
|
+
const scannedMessages = messages.map((msg) => {
|
|
2297
|
+
const findings = scanContentForSecrets(msg.content, `message:${msg.role}`);
|
|
2298
|
+
if (findings.length === 0) return msg;
|
|
2299
|
+
const criticalCount = findings.filter((f) => f.severity === "critical").length;
|
|
2300
|
+
if (config.blockOnSecrets && criticalCount > 0) {
|
|
2301
|
+
blocked = true;
|
|
2302
|
+
scanDecisions.push(
|
|
2303
|
+
`BLOCKED: ${criticalCount} critical secret(s) in ${msg.role} message. Types: ${[...new Set(findings.map((f) => f.type))].join(", ")}`
|
|
2304
|
+
);
|
|
2305
|
+
return msg;
|
|
2306
|
+
}
|
|
2307
|
+
const sanitized = sanitizeContent(msg.content);
|
|
2308
|
+
redactedCount += findings.length;
|
|
2309
|
+
scanDecisions.push(
|
|
2310
|
+
`Redacted ${findings.length} secret(s) in ${msg.role} message: ${[...new Set(findings.map((f) => f.type))].join(", ")}`
|
|
2311
|
+
);
|
|
2312
|
+
return { ...msg, content: sanitized };
|
|
2313
|
+
});
|
|
2314
|
+
return { messages: scannedMessages, redactedCount, blocked, scanDecisions };
|
|
2315
|
+
}
|
|
2316
|
+
async function optimizeContext(messages, analysis, config) {
|
|
2317
|
+
const optimizeDecisions = [];
|
|
2318
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
2319
|
+
if (!lastUserMsg) {
|
|
2320
|
+
optimizeDecisions.push("No user message found \u2014 skipping optimization");
|
|
2321
|
+
return { messages, injected: false, optimizeDecisions };
|
|
2322
|
+
}
|
|
2323
|
+
const hasCtxContext = messages.some(
|
|
2324
|
+
(m) => m.role === "system" && m.content.includes("[CTO Context]")
|
|
2325
|
+
);
|
|
2326
|
+
if (hasCtxContext) {
|
|
2327
|
+
optimizeDecisions.push("CTO context already present \u2014 skipping injection");
|
|
2328
|
+
return { messages, injected: false, optimizeDecisions };
|
|
2329
|
+
}
|
|
2330
|
+
try {
|
|
2331
|
+
const selection = await selectContext({
|
|
2332
|
+
task: lastUserMsg.content.slice(0, 500),
|
|
2333
|
+
analysis,
|
|
2334
|
+
budget: config.budget
|
|
2335
|
+
});
|
|
2336
|
+
if (selection.files.length === 0) {
|
|
2337
|
+
optimizeDecisions.push("No relevant files found for task \u2014 skipping injection");
|
|
2338
|
+
return { messages, injected: false, optimizeDecisions };
|
|
2339
|
+
}
|
|
2340
|
+
const contentBudget = Math.floor(config.budget * 0.6);
|
|
2341
|
+
let usedTokens = 0;
|
|
2342
|
+
const contextLines = [
|
|
2343
|
+
"[CTO Context] Optimized project context (auto-injected by CTO Gateway)",
|
|
2344
|
+
"",
|
|
2345
|
+
`Project: ${analysis.projectName} (${analysis.totalFiles} files, ${Math.round(analysis.totalTokens / 1e3)}K tokens)`,
|
|
2346
|
+
`Selected: ${selection.files.length} files, ${selection.totalTokens.toLocaleString()} tokens (${selection.coverage.score}% coverage)`,
|
|
2347
|
+
""
|
|
2348
|
+
];
|
|
2349
|
+
const topFiles = selection.files.sort((a, b) => b.riskScore - a.riskScore);
|
|
2350
|
+
const injectedFiles = [];
|
|
2351
|
+
const skippedFiles = [];
|
|
2352
|
+
for (const f of topFiles) {
|
|
2353
|
+
if (usedTokens >= contentBudget) {
|
|
2354
|
+
skippedFiles.push(f.relativePath);
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
try {
|
|
2358
|
+
const fullPath = resolve6(config.projectPath, f.relativePath);
|
|
2359
|
+
const content = readFileSync2(fullPath, "utf-8");
|
|
2360
|
+
const fileTokens = estimateTokensFromString(content);
|
|
2361
|
+
const remainingBudget = contentBudget - usedTokens;
|
|
2362
|
+
let fileContent;
|
|
2363
|
+
let truncated = false;
|
|
2364
|
+
if (fileTokens > remainingBudget) {
|
|
2365
|
+
const charLimit = remainingBudget * 4;
|
|
2366
|
+
fileContent = content.slice(0, charLimit);
|
|
2367
|
+
truncated = true;
|
|
2368
|
+
} else {
|
|
2369
|
+
fileContent = content;
|
|
2370
|
+
}
|
|
2371
|
+
const ext = f.relativePath.split(".").pop() || "";
|
|
2372
|
+
contextLines.push(`### ${f.relativePath}${truncated ? " [truncated]" : ""}`);
|
|
2373
|
+
contextLines.push("```" + ext);
|
|
2374
|
+
contextLines.push(fileContent);
|
|
2375
|
+
contextLines.push("```");
|
|
2376
|
+
contextLines.push("");
|
|
2377
|
+
usedTokens += estimateTokensFromString(fileContent);
|
|
2378
|
+
injectedFiles.push(f.relativePath);
|
|
2379
|
+
} catch {
|
|
2380
|
+
skippedFiles.push(f.relativePath);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
const typeFiles = analysis.files.filter((f) => f.kind === "type").map((f) => f.relativePath);
|
|
2384
|
+
if (typeFiles.length > 0) {
|
|
2385
|
+
contextLines.push("Type definitions (always import from these):");
|
|
2386
|
+
for (const tf of typeFiles.slice(0, 10)) {
|
|
2387
|
+
contextLines.push(` - ${tf}`);
|
|
2388
|
+
}
|
|
2389
|
+
contextLines.push("");
|
|
2390
|
+
}
|
|
2391
|
+
if (analysis.graph.hubs.length > 0) {
|
|
2392
|
+
contextLines.push("Hub files (central modules with many dependents):");
|
|
2393
|
+
for (const hub of analysis.graph.hubs.slice(0, 5)) {
|
|
2394
|
+
contextLines.push(` - ${hub.relativePath} (${hub.dependents} dependents)`);
|
|
2395
|
+
}
|
|
2396
|
+
contextLines.push("");
|
|
2397
|
+
}
|
|
2398
|
+
if (skippedFiles.length > 0) {
|
|
2399
|
+
contextLines.push(`Additional relevant files (not included due to token budget):`);
|
|
2400
|
+
for (const sf of skippedFiles.slice(0, 15)) {
|
|
2401
|
+
contextLines.push(` - ${sf}`);
|
|
2402
|
+
}
|
|
2403
|
+
if (skippedFiles.length > 15) {
|
|
2404
|
+
contextLines.push(` ... and ${skippedFiles.length - 15} more`);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
const contextBlock = contextLines.join("\n");
|
|
2408
|
+
const systemMsg = {
|
|
2409
|
+
role: "system",
|
|
2410
|
+
content: contextBlock
|
|
2411
|
+
};
|
|
2412
|
+
const existingSystemIdx = messages.findIndex((m) => m.role === "system");
|
|
2413
|
+
let optimizedMessages;
|
|
2414
|
+
if (existingSystemIdx >= 0) {
|
|
2415
|
+
optimizedMessages = [...messages];
|
|
2416
|
+
optimizedMessages[existingSystemIdx] = {
|
|
2417
|
+
...optimizedMessages[existingSystemIdx],
|
|
2418
|
+
content: optimizedMessages[existingSystemIdx].content + "\n\n" + contextBlock
|
|
2419
|
+
};
|
|
2420
|
+
} else {
|
|
2421
|
+
optimizedMessages = [systemMsg, ...messages];
|
|
2422
|
+
}
|
|
2423
|
+
optimizeDecisions.push(
|
|
2424
|
+
`Injected CTO context: ${injectedFiles.length} files with contents (${usedTokens.toLocaleString()} tokens), ${skippedFiles.length} listed without contents, ${selection.coverage.score}% coverage`
|
|
2425
|
+
);
|
|
2426
|
+
return { messages: optimizedMessages, injected: true, optimizeDecisions };
|
|
2427
|
+
} catch (err) {
|
|
2428
|
+
optimizeDecisions.push(`Context optimization failed: ${err.message}`);
|
|
2429
|
+
return { messages, injected: false, optimizeDecisions };
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
var init_interceptor = __esm({
|
|
2433
|
+
"src/gateway/interceptor.ts"() {
|
|
2434
|
+
"use strict";
|
|
2435
|
+
init_secrets();
|
|
2436
|
+
init_selector();
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
|
|
2440
|
+
// src/gateway/tracker.ts
|
|
2441
|
+
import { mkdirSync as mkdirSync2, appendFileSync, readFileSync as readFileSync3, readdirSync, existsSync as existsSync6 } from "fs";
|
|
2442
|
+
import { join as join7 } from "path";
|
|
2443
|
+
import { randomUUID } from "crypto";
|
|
2444
|
+
var UsageTracker;
|
|
2445
|
+
var init_tracker = __esm({
|
|
2446
|
+
"src/gateway/tracker.ts"() {
|
|
2447
|
+
"use strict";
|
|
2448
|
+
UsageTracker = class {
|
|
2449
|
+
logDir;
|
|
2450
|
+
config;
|
|
2451
|
+
eventHandlers = [];
|
|
2452
|
+
cache = null;
|
|
2453
|
+
cacheMonth = null;
|
|
2454
|
+
// In-memory cost accumulators — survive async disk writes
|
|
2455
|
+
memRecords = [];
|
|
2456
|
+
constructor(config) {
|
|
2457
|
+
this.config = config;
|
|
2458
|
+
this.logDir = join7(config.logDir, "usage");
|
|
2459
|
+
mkdirSync2(this.logDir, { recursive: true });
|
|
2460
|
+
}
|
|
2461
|
+
// ===== EVENT SYSTEM =====
|
|
2462
|
+
onEvent(handler) {
|
|
2463
|
+
this.eventHandlers.push(handler);
|
|
2464
|
+
}
|
|
2465
|
+
emit(event) {
|
|
2466
|
+
for (const handler of this.eventHandlers) {
|
|
2467
|
+
try {
|
|
2468
|
+
handler(event);
|
|
2469
|
+
} catch {
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
// ===== RECORD =====
|
|
2474
|
+
record(params) {
|
|
2475
|
+
const record = {
|
|
2476
|
+
id: randomUUID().slice(0, 8),
|
|
2477
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2478
|
+
...params
|
|
2479
|
+
};
|
|
2480
|
+
const monthKey = this.getMonthKey(record.timestamp);
|
|
2481
|
+
const logFile = join7(this.logDir, `${monthKey}.jsonl`);
|
|
2482
|
+
const line = JSON.stringify({
|
|
2483
|
+
...record,
|
|
2484
|
+
timestamp: record.timestamp.toISOString()
|
|
2485
|
+
});
|
|
2486
|
+
appendFileSync(logFile, line + "\n");
|
|
2487
|
+
this.memRecords.push(record);
|
|
2488
|
+
this.cache = null;
|
|
2489
|
+
this.emit({ type: "request", record });
|
|
2490
|
+
this.checkBudget(record.timestamp);
|
|
2491
|
+
return record;
|
|
2492
|
+
}
|
|
2493
|
+
// ===== BUDGET CHECKS =====
|
|
2494
|
+
checkBudget(now) {
|
|
2495
|
+
if (this.config.budgetDaily > 0) {
|
|
2496
|
+
const dailyCost = this.getDailyCost(now);
|
|
2497
|
+
const threshold = this.config.budgetDaily * this.config.alertThreshold;
|
|
2498
|
+
if (dailyCost >= this.config.budgetDaily) {
|
|
2499
|
+
this.emit({
|
|
2500
|
+
type: "budget-exceeded",
|
|
2501
|
+
current: dailyCost,
|
|
2502
|
+
limit: this.config.budgetDaily,
|
|
2503
|
+
period: "daily"
|
|
2504
|
+
});
|
|
2505
|
+
} else if (dailyCost >= threshold) {
|
|
2506
|
+
this.emit({
|
|
2507
|
+
type: "budget-alert",
|
|
2508
|
+
current: dailyCost,
|
|
2509
|
+
limit: this.config.budgetDaily,
|
|
2510
|
+
period: "daily"
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
if (this.config.budgetMonthly > 0) {
|
|
2515
|
+
const monthlyCost = this.getMonthlyCost(now);
|
|
2516
|
+
const threshold = this.config.budgetMonthly * this.config.alertThreshold;
|
|
2517
|
+
if (monthlyCost >= this.config.budgetMonthly) {
|
|
2518
|
+
this.emit({
|
|
2519
|
+
type: "budget-exceeded",
|
|
2520
|
+
current: monthlyCost,
|
|
2521
|
+
limit: this.config.budgetMonthly,
|
|
2522
|
+
period: "monthly"
|
|
2523
|
+
});
|
|
2524
|
+
} else if (monthlyCost >= threshold) {
|
|
2525
|
+
this.emit({
|
|
2526
|
+
type: "budget-alert",
|
|
2527
|
+
current: monthlyCost,
|
|
2528
|
+
limit: this.config.budgetMonthly,
|
|
2529
|
+
period: "monthly"
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
isDailyBudgetExceeded(now = /* @__PURE__ */ new Date()) {
|
|
2535
|
+
if (this.config.budgetDaily <= 0) return false;
|
|
2536
|
+
return this.getDailyCost(now) >= this.config.budgetDaily;
|
|
2537
|
+
}
|
|
2538
|
+
isMonthlyBudgetExceeded(now = /* @__PURE__ */ new Date()) {
|
|
2539
|
+
if (this.config.budgetMonthly <= 0) return false;
|
|
2540
|
+
return this.getMonthlyCost(now) >= this.config.budgetMonthly;
|
|
2541
|
+
}
|
|
2542
|
+
// ===== QUERIES =====
|
|
2543
|
+
getDailyCost(date = /* @__PURE__ */ new Date()) {
|
|
2544
|
+
const dayStr = date.toISOString().split("T")[0];
|
|
2545
|
+
const diskRecords = this.getMonthRecords(date);
|
|
2546
|
+
const allRecords = this.mergeWithMemRecords(diskRecords);
|
|
2547
|
+
return allRecords.filter((r) => r.timestamp.toISOString().startsWith(dayStr)).reduce((sum, r) => sum + r.costUSD, 0);
|
|
2548
|
+
}
|
|
2549
|
+
getMonthlyCost(date = /* @__PURE__ */ new Date()) {
|
|
2550
|
+
const diskRecords = this.getMonthRecords(date);
|
|
2551
|
+
const allRecords = this.mergeWithMemRecords(diskRecords);
|
|
2552
|
+
return allRecords.reduce((sum, r) => sum + r.costUSD, 0);
|
|
2553
|
+
}
|
|
2554
|
+
mergeWithMemRecords(diskRecords) {
|
|
2555
|
+
if (this.memRecords.length === 0) return diskRecords;
|
|
2556
|
+
const diskIds = new Set(diskRecords.map((r) => r.id));
|
|
2557
|
+
const newRecords = this.memRecords.filter((r) => !diskIds.has(r.id));
|
|
2558
|
+
return [...diskRecords, ...newRecords];
|
|
2559
|
+
}
|
|
2560
|
+
getSummary(period = "month") {
|
|
2561
|
+
const now = /* @__PURE__ */ new Date();
|
|
2562
|
+
let records;
|
|
2563
|
+
switch (period) {
|
|
2564
|
+
case "day": {
|
|
2565
|
+
const dayStr = now.toISOString().split("T")[0];
|
|
2566
|
+
records = this.mergeWithMemRecords(this.getMonthRecords(now)).filter(
|
|
2567
|
+
(r) => r.timestamp.toISOString().startsWith(dayStr)
|
|
2568
|
+
);
|
|
2569
|
+
break;
|
|
2570
|
+
}
|
|
2571
|
+
case "week": {
|
|
2572
|
+
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
|
|
2573
|
+
const weekAgoKey = this.getMonthKey(weekAgo);
|
|
2574
|
+
const nowKey = this.getMonthKey(now);
|
|
2575
|
+
const baseRecs = weekAgoKey !== nowKey ? [...this.getMonthRecordsByKey(weekAgoKey), ...this.getMonthRecords(now)] : this.getMonthRecords(now);
|
|
2576
|
+
records = this.mergeWithMemRecords(baseRecs).filter((r) => r.timestamp >= weekAgo);
|
|
2577
|
+
break;
|
|
2578
|
+
}
|
|
2579
|
+
case "month":
|
|
2580
|
+
records = this.mergeWithMemRecords(this.getMonthRecords(now));
|
|
2581
|
+
break;
|
|
2582
|
+
case "all":
|
|
2583
|
+
records = this.mergeWithMemRecords(this.getAllRecords());
|
|
2584
|
+
break;
|
|
2585
|
+
}
|
|
2586
|
+
const byModel = {};
|
|
2587
|
+
const byProvider = {};
|
|
2588
|
+
for (const r of records) {
|
|
2589
|
+
if (!byModel[r.model]) byModel[r.model] = { requests: 0, costUSD: 0, tokens: 0 };
|
|
2590
|
+
byModel[r.model].requests++;
|
|
2591
|
+
byModel[r.model].costUSD += r.costUSD;
|
|
2592
|
+
byModel[r.model].tokens += r.inputTokens + r.outputTokens;
|
|
2593
|
+
if (!byProvider[r.provider]) byProvider[r.provider] = { requests: 0, costUSD: 0 };
|
|
2594
|
+
byProvider[r.provider].requests++;
|
|
2595
|
+
byProvider[r.provider].costUSD += r.costUSD;
|
|
2596
|
+
}
|
|
2597
|
+
return {
|
|
2598
|
+
period,
|
|
2599
|
+
totalRequests: records.length,
|
|
2600
|
+
totalInputTokens: records.reduce((s, r) => s + r.inputTokens, 0),
|
|
2601
|
+
totalOutputTokens: records.reduce((s, r) => s + r.outputTokens, 0),
|
|
2602
|
+
totalCostUSD: records.reduce((s, r) => s + r.costUSD, 0),
|
|
2603
|
+
totalSavedTokens: records.reduce((s, r) => s + r.savedTokens, 0),
|
|
2604
|
+
totalSavedUSD: records.reduce((s, r) => s + r.savedUSD, 0),
|
|
2605
|
+
totalSecretsRedacted: records.reduce((s, r) => s + r.secretsRedacted, 0),
|
|
2606
|
+
byModel,
|
|
2607
|
+
byProvider
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
// ===== STORAGE =====
|
|
2611
|
+
getMonthKey(date) {
|
|
2612
|
+
return date.toISOString().slice(0, 7);
|
|
2613
|
+
}
|
|
2614
|
+
getMonthRecordsByKey(monthKey) {
|
|
2615
|
+
const filePath = join7(this.logDir, `${monthKey}.jsonl`);
|
|
2616
|
+
if (!existsSync6(filePath)) return [];
|
|
2617
|
+
return readFileSync3(filePath, "utf-8").split("\n").filter((line) => line.trim()).map((line) => {
|
|
2618
|
+
try {
|
|
2619
|
+
const parsed = JSON.parse(line);
|
|
2620
|
+
parsed.timestamp = new Date(parsed.timestamp);
|
|
2621
|
+
return parsed;
|
|
2622
|
+
} catch {
|
|
2623
|
+
return null;
|
|
2624
|
+
}
|
|
2625
|
+
}).filter((r) => r !== null);
|
|
2626
|
+
}
|
|
2627
|
+
getMonthRecords(date) {
|
|
2628
|
+
const monthKey = this.getMonthKey(date);
|
|
2629
|
+
if (this.cache && this.cacheMonth === monthKey) return this.cache;
|
|
2630
|
+
const filePath = join7(this.logDir, `${monthKey}.jsonl`);
|
|
2631
|
+
if (!existsSync6(filePath)) return [];
|
|
2632
|
+
const records = readFileSync3(filePath, "utf-8").split("\n").filter((line) => line.trim()).map((line) => {
|
|
2633
|
+
try {
|
|
2634
|
+
const parsed = JSON.parse(line);
|
|
2635
|
+
parsed.timestamp = new Date(parsed.timestamp);
|
|
2636
|
+
return parsed;
|
|
2637
|
+
} catch {
|
|
2638
|
+
return null;
|
|
2639
|
+
}
|
|
2640
|
+
}).filter((r) => r !== null);
|
|
2641
|
+
this.cache = records;
|
|
2642
|
+
this.cacheMonth = monthKey;
|
|
2643
|
+
return records;
|
|
2644
|
+
}
|
|
2645
|
+
getAllRecords() {
|
|
2646
|
+
if (!existsSync6(this.logDir)) return [];
|
|
2647
|
+
const files = readdirSync(this.logDir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
2648
|
+
const allRecords = [];
|
|
2649
|
+
for (const file of files) {
|
|
2650
|
+
const content = readFileSync3(join7(this.logDir, file), "utf-8");
|
|
2651
|
+
const records = content.split("\n").filter((line) => line.trim()).map((line) => {
|
|
2652
|
+
try {
|
|
2653
|
+
const parsed = JSON.parse(line);
|
|
2654
|
+
parsed.timestamp = new Date(parsed.timestamp);
|
|
2655
|
+
return parsed;
|
|
2656
|
+
} catch {
|
|
2657
|
+
return null;
|
|
2658
|
+
}
|
|
2659
|
+
}).filter((r) => r !== null);
|
|
2660
|
+
allRecords.push(...records);
|
|
2661
|
+
}
|
|
2662
|
+
return allRecords;
|
|
2663
|
+
}
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
// src/gateway/server.ts
|
|
2669
|
+
var server_exports = {};
|
|
2670
|
+
__export(server_exports, {
|
|
2671
|
+
ContextGateway: () => ContextGateway
|
|
2672
|
+
});
|
|
2673
|
+
import { createServer, Agent as HttpAgent } from "http";
|
|
2674
|
+
import { request as httpsRequest, Agent as HttpsAgent } from "https";
|
|
2675
|
+
import { request as httpRequest } from "http";
|
|
2676
|
+
import { URL } from "url";
|
|
2677
|
+
import { lookup } from "dns/promises";
|
|
2678
|
+
function readBody(req, maxBytes = 0) {
|
|
2679
|
+
return new Promise((resolve8, reject) => {
|
|
2680
|
+
const chunks = [];
|
|
2681
|
+
let totalBytes = 0;
|
|
2682
|
+
req.on("data", (chunk) => {
|
|
2683
|
+
totalBytes += chunk.length;
|
|
2684
|
+
if (maxBytes > 0 && totalBytes > maxBytes) {
|
|
2685
|
+
req.destroy();
|
|
2686
|
+
reject(new Error("body-too-large"));
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
chunks.push(chunk);
|
|
2690
|
+
});
|
|
2691
|
+
req.on("end", () => resolve8(Buffer.concat(chunks).toString()));
|
|
2692
|
+
req.on("error", reject);
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
function flattenHeaders(headers) {
|
|
2696
|
+
const flat = {};
|
|
2697
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2698
|
+
if (value) flat[key] = Array.isArray(value) ? value[0] : value;
|
|
2699
|
+
}
|
|
2700
|
+
return flat;
|
|
2701
|
+
}
|
|
2702
|
+
function rebuildRequestBody(original, messages, provider) {
|
|
2703
|
+
const body = { ...original };
|
|
2704
|
+
if (provider === "anthropic") {
|
|
2705
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
2706
|
+
const otherMsgs = messages.filter((m) => m.role !== "system");
|
|
2707
|
+
if (systemMsg) body.system = systemMsg.content;
|
|
2708
|
+
body.messages = otherMsgs;
|
|
2709
|
+
} else if (provider === "google") {
|
|
2710
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
2711
|
+
const otherMsgs = messages.filter((m) => m.role !== "system");
|
|
2712
|
+
if (systemMsg) {
|
|
2713
|
+
body.systemInstruction = { parts: [{ text: systemMsg.content }] };
|
|
2714
|
+
}
|
|
2715
|
+
body.contents = otherMsgs.map((m) => ({
|
|
2716
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
2717
|
+
parts: [{ text: m.content }]
|
|
2718
|
+
}));
|
|
2719
|
+
} else {
|
|
2720
|
+
body.messages = messages;
|
|
2721
|
+
}
|
|
2722
|
+
return JSON.stringify(body);
|
|
2723
|
+
}
|
|
2724
|
+
function generateDashboardHTML(monthly, daily, config, analysis) {
|
|
2725
|
+
const modelRows = Object.entries(monthly.byModel).sort(([, a], [, b]) => b.costUSD - a.costUSD).map(
|
|
2726
|
+
([model, data]) => `<tr><td>${model}</td><td>${data.requests}</td><td>${(data.tokens / 1e3).toFixed(1)}K</td><td>$${data.costUSD.toFixed(4)}</td></tr>`
|
|
2727
|
+
).join("");
|
|
2728
|
+
const providerRows = Object.entries(monthly.byProvider).sort(([, a], [, b]) => b.costUSD - a.costUSD).map(
|
|
2729
|
+
([provider, data]) => `<tr><td>${provider}</td><td>${data.requests}</td><td>$${data.costUSD.toFixed(4)}</td></tr>`
|
|
2730
|
+
).join("");
|
|
2731
|
+
return `<!DOCTYPE html>
|
|
2732
|
+
<html lang="en">
|
|
2733
|
+
<head>
|
|
2734
|
+
<meta charset="utf-8">
|
|
2735
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2736
|
+
<title>CTO Gateway Dashboard</title>
|
|
2737
|
+
<style>
|
|
2738
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2739
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0f; color: #e0e0e0; padding: 2rem; }
|
|
2740
|
+
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: #fff; }
|
|
2741
|
+
h2 { font-size: 1.2rem; margin: 2rem 0 1rem; color: #8b8bff; }
|
|
2742
|
+
.subtitle { color: #666; margin-bottom: 2rem; }
|
|
2743
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
2744
|
+
.card { background: #14141f; border: 1px solid #2a2a3a; border-radius: 12px; padding: 1.5rem; }
|
|
2745
|
+
.card .label { font-size: 0.75rem; text-transform: uppercase; color: #666; letter-spacing: 0.05em; }
|
|
2746
|
+
.card .value { font-size: 2rem; font-weight: 700; color: #fff; margin-top: 0.25rem; }
|
|
2747
|
+
.card .detail { font-size: 0.85rem; color: #888; margin-top: 0.25rem; }
|
|
2748
|
+
.card.green .value { color: #4ade80; }
|
|
2749
|
+
.card.red .value { color: #f87171; }
|
|
2750
|
+
.card.blue .value { color: #60a5fa; }
|
|
2751
|
+
.card.purple .value { color: #a78bfa; }
|
|
2752
|
+
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
|
2753
|
+
th { text-align: left; padding: 0.5rem; color: #666; font-size: 0.75rem; text-transform: uppercase; border-bottom: 1px solid #2a2a3a; }
|
|
2754
|
+
td { padding: 0.5rem; border-bottom: 1px solid #1a1a2a; font-size: 0.9rem; }
|
|
2755
|
+
.status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; }
|
|
2756
|
+
.status.on { background: #4ade80; }
|
|
2757
|
+
.status.off { background: #666; }
|
|
2758
|
+
.footer { margin-top: 3rem; color: #444; font-size: 0.75rem; }
|
|
2759
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
|
2760
|
+
.badge.critical { background: #7f1d1d; color: #fca5a5; }
|
|
2761
|
+
.badge.ok { background: #14532d; color: #86efac; }
|
|
2762
|
+
</style>
|
|
2763
|
+
</head>
|
|
2764
|
+
<body>
|
|
2765
|
+
<h1>\u26A1 CTO Context Gateway</h1>
|
|
2766
|
+
<p class="subtitle">Real-time AI proxy with context optimization, secret redaction, and cost tracking</p>
|
|
2767
|
+
|
|
2768
|
+
<h2>Today</h2>
|
|
2769
|
+
<div class="grid">
|
|
2770
|
+
<div class="card blue">
|
|
2771
|
+
<div class="label">Requests</div>
|
|
2772
|
+
<div class="value">${daily.totalRequests}</div>
|
|
2773
|
+
</div>
|
|
2774
|
+
<div class="card">
|
|
2775
|
+
<div class="label">Cost</div>
|
|
2776
|
+
<div class="value">$${daily.totalCostUSD.toFixed(2)}</div>
|
|
2777
|
+
${config.budgetDaily > 0 ? `<div class="detail">Budget: $${config.budgetDaily}/day</div>` : ""}
|
|
2778
|
+
</div>
|
|
2779
|
+
<div class="card green">
|
|
2780
|
+
<div class="label">Tokens Saved</div>
|
|
2781
|
+
<div class="value">${(daily.totalSavedTokens / 1e3).toFixed(1)}K</div>
|
|
2782
|
+
<div class="detail">$${daily.totalSavedUSD.toFixed(2)} saved</div>
|
|
2783
|
+
</div>
|
|
2784
|
+
<div class="card ${daily.totalSecretsRedacted > 0 ? "red" : ""}">
|
|
2785
|
+
<div class="label">Secrets Redacted</div>
|
|
2786
|
+
<div class="value">${daily.totalSecretsRedacted}</div>
|
|
2787
|
+
</div>
|
|
2788
|
+
</div>
|
|
2789
|
+
|
|
2790
|
+
<h2>This Month</h2>
|
|
2791
|
+
<div class="grid">
|
|
2792
|
+
<div class="card blue">
|
|
2793
|
+
<div class="label">Total Requests</div>
|
|
2794
|
+
<div class="value">${monthly.totalRequests}</div>
|
|
2795
|
+
</div>
|
|
2796
|
+
<div class="card">
|
|
2797
|
+
<div class="label">Total Cost</div>
|
|
2798
|
+
<div class="value">$${monthly.totalCostUSD.toFixed(2)}</div>
|
|
2799
|
+
${config.budgetMonthly > 0 ? `<div class="detail">Budget: $${config.budgetMonthly}/month</div>` : ""}
|
|
2800
|
+
</div>
|
|
2801
|
+
<div class="card green">
|
|
2802
|
+
<div class="label">Total Saved</div>
|
|
2803
|
+
<div class="value">$${monthly.totalSavedUSD.toFixed(2)}</div>
|
|
2804
|
+
<div class="detail">${(monthly.totalSavedTokens / 1e3).toFixed(0)}K tokens</div>
|
|
2805
|
+
</div>
|
|
2806
|
+
<div class="card purple">
|
|
2807
|
+
<div class="label">Tokens Processed</div>
|
|
2808
|
+
<div class="value">${((monthly.totalInputTokens + monthly.totalOutputTokens) / 1e3).toFixed(0)}K</div>
|
|
2809
|
+
<div class="detail">${(monthly.totalInputTokens / 1e3).toFixed(0)}K in / ${(monthly.totalOutputTokens / 1e3).toFixed(0)}K out</div>
|
|
2810
|
+
</div>
|
|
2811
|
+
</div>
|
|
2812
|
+
|
|
2813
|
+
<h2>Features</h2>
|
|
2814
|
+
<div class="grid">
|
|
2815
|
+
<div class="card">
|
|
2816
|
+
<div class="label">Context Optimization</div>
|
|
2817
|
+
<div class="value"><span class="status ${config.optimize ? "on" : "off"}"></span>${config.optimize ? "ON" : "OFF"}</div>
|
|
2818
|
+
${analysis ? `<div class="detail">${analysis.totalFiles} files, ${(analysis.totalTokens / 1e3).toFixed(0)}K tokens</div>` : '<div class="detail">Loading analysis...</div>'}
|
|
2819
|
+
</div>
|
|
2820
|
+
<div class="card">
|
|
2821
|
+
<div class="label">Secret Redaction</div>
|
|
2822
|
+
<div class="value"><span class="status ${config.redactSecrets ? "on" : "off"}"></span>${config.redactSecrets ? "ON" : "OFF"}</div>
|
|
2823
|
+
${config.blockOnSecrets ? '<span class="badge critical">BLOCKING</span>' : ""}
|
|
2824
|
+
</div>
|
|
2825
|
+
<div class="card">
|
|
2826
|
+
<div class="label">Cost Tracking</div>
|
|
2827
|
+
<div class="value"><span class="status ${config.costTracking ? "on" : "off"}"></span>${config.costTracking ? "ON" : "OFF"}</div>
|
|
2828
|
+
</div>
|
|
2829
|
+
<div class="card">
|
|
2830
|
+
<div class="label">Audit Log</div>
|
|
2831
|
+
<div class="value"><span class="status ${config.auditLog ? "on" : "off"}"></span>${config.auditLog ? "ON" : "OFF"}</div>
|
|
2832
|
+
<div class="detail">${config.logDir}</div>
|
|
2833
|
+
</div>
|
|
2834
|
+
</div>
|
|
2835
|
+
|
|
2836
|
+
${modelRows ? `
|
|
2837
|
+
<h2>By Model</h2>
|
|
2838
|
+
<div class="card">
|
|
2839
|
+
<table>
|
|
2840
|
+
<thead><tr><th>Model</th><th>Requests</th><th>Tokens</th><th>Cost</th></tr></thead>
|
|
2841
|
+
<tbody>${modelRows}</tbody>
|
|
2842
|
+
</table>
|
|
2843
|
+
</div>` : ""}
|
|
2844
|
+
|
|
2845
|
+
${providerRows ? `
|
|
2846
|
+
<h2>By Provider</h2>
|
|
2847
|
+
<div class="card">
|
|
2848
|
+
<table>
|
|
2849
|
+
<thead><tr><th>Provider</th><th>Requests</th><th>Cost</th></tr></thead>
|
|
2850
|
+
<tbody>${providerRows}</tbody>
|
|
2851
|
+
</table>
|
|
2852
|
+
</div>` : ""}
|
|
2853
|
+
|
|
2854
|
+
<div class="footer">
|
|
2855
|
+
CTO Context Gateway v4.0.0 \xB7 Listening on ${config.host}:${config.port} \xB7 <a href="/health" style="color:#666">Health</a>
|
|
2856
|
+
</div>
|
|
2857
|
+
|
|
2858
|
+
<script>setTimeout(() => location.reload(), 30000);</script>
|
|
2859
|
+
</body>
|
|
2860
|
+
</html>`;
|
|
2861
|
+
}
|
|
2862
|
+
var ContextGateway;
|
|
2863
|
+
var init_server = __esm({
|
|
2864
|
+
"src/gateway/server.ts"() {
|
|
2865
|
+
"use strict";
|
|
2866
|
+
init_types();
|
|
2867
|
+
init_providers();
|
|
2868
|
+
init_interceptor();
|
|
2869
|
+
init_tracker();
|
|
2870
|
+
init_analyzer();
|
|
2871
|
+
ContextGateway = class {
|
|
2872
|
+
config;
|
|
2873
|
+
tracker;
|
|
2874
|
+
analysis = null;
|
|
2875
|
+
analysisPromise = null;
|
|
2876
|
+
eventHandlers = [];
|
|
2877
|
+
server = null;
|
|
2878
|
+
httpAgent;
|
|
2879
|
+
httpsAgent;
|
|
2880
|
+
budgetLock = false;
|
|
2881
|
+
// Simple lock for budget reservation
|
|
2882
|
+
constructor(config = {}) {
|
|
2883
|
+
this.config = { ...DEFAULT_GATEWAY_CONFIG, ...config };
|
|
2884
|
+
this.tracker = new UsageTracker(this.config);
|
|
2885
|
+
this.httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
|
|
2886
|
+
this.httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
|
|
2887
|
+
this.tracker.onEvent((event) => this.emit(event));
|
|
2888
|
+
}
|
|
2889
|
+
// ===== EVENTS =====
|
|
2890
|
+
onEvent(handler) {
|
|
2891
|
+
this.eventHandlers.push(handler);
|
|
2892
|
+
}
|
|
2893
|
+
emit(event) {
|
|
2894
|
+
for (const handler of this.eventHandlers) {
|
|
2895
|
+
try {
|
|
2896
|
+
handler(event);
|
|
2897
|
+
} catch {
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
// ===== LIFECYCLE =====
|
|
2902
|
+
async start() {
|
|
2903
|
+
if (this.config.optimize) {
|
|
2904
|
+
this.analysisPromise = this.refreshAnalysis();
|
|
2905
|
+
}
|
|
2906
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
2907
|
+
return new Promise((resolve8) => {
|
|
2908
|
+
this.server.listen(this.config.port, this.config.host, () => {
|
|
2909
|
+
resolve8();
|
|
2910
|
+
});
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
async stop() {
|
|
2914
|
+
return new Promise((resolve8) => {
|
|
2915
|
+
if (this.server) {
|
|
2916
|
+
this.server.close(() => resolve8());
|
|
2917
|
+
} else {
|
|
2918
|
+
resolve8();
|
|
2919
|
+
}
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
getTracker() {
|
|
2923
|
+
return this.tracker;
|
|
2924
|
+
}
|
|
2925
|
+
// ===== ANALYSIS =====
|
|
2926
|
+
async refreshAnalysis() {
|
|
2927
|
+
try {
|
|
2928
|
+
const analysis = await analyzeProject(this.config.projectPath);
|
|
2929
|
+
this.analysis = analysis;
|
|
2930
|
+
return analysis;
|
|
2931
|
+
} catch (err) {
|
|
2932
|
+
this.emit({ type: "error", message: `Analysis failed: ${err.message}`, error: err });
|
|
2933
|
+
throw err;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
// ===== REQUEST HANDLER =====
|
|
2937
|
+
async handleRequest(req, res) {
|
|
2938
|
+
const startTime = Date.now();
|
|
2939
|
+
if (this.config.dashboard && req.url?.startsWith(this.config.dashboardPath)) {
|
|
2940
|
+
return this.serveDashboard(req, res);
|
|
2941
|
+
}
|
|
2942
|
+
if (req.url === "/health" || req.url === "/__cto/health") {
|
|
2943
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2944
|
+
res.end(JSON.stringify({
|
|
2945
|
+
status: "ok",
|
|
2946
|
+
version: "4.0.0",
|
|
2947
|
+
uptime: process.uptime(),
|
|
2948
|
+
analysis: this.analysis ? "ready" : "loading"
|
|
2949
|
+
}));
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
if (this.config.apiKey) {
|
|
2953
|
+
const authHeader = req.headers["x-cto-key"] || req.headers["authorization"]?.replace(/^Bearer\s+/i, "") || "";
|
|
2954
|
+
if (authHeader !== this.config.apiKey) {
|
|
2955
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2956
|
+
res.end(JSON.stringify({ error: "Unauthorized. Set x-cto-key header or Authorization: Bearer <key>" }));
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
if (req.method !== "POST") {
|
|
2961
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
2962
|
+
res.end(JSON.stringify({ error: "Method not allowed. Gateway only proxies POST requests." }));
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
let body;
|
|
2966
|
+
try {
|
|
2967
|
+
body = await readBody(req, this.config.maxBodyBytes);
|
|
2968
|
+
} catch (err) {
|
|
2969
|
+
const status = err.message === "body-too-large" ? 413 : 400;
|
|
2970
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2971
|
+
res.end(JSON.stringify({ error: status === 413 ? `Request body too large. Max: ${Math.round(this.config.maxBodyBytes / 1024 / 1024)}MB` : "Failed to read request body" }));
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
let parsedBody;
|
|
2975
|
+
try {
|
|
2976
|
+
parsedBody = JSON.parse(body);
|
|
2977
|
+
} catch {
|
|
2978
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2979
|
+
res.end(JSON.stringify({ error: "Invalid JSON in request body" }));
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
const targetUrl = req.headers["x-cto-target"] || req.headers["x-target-url"] || "";
|
|
2983
|
+
if (!targetUrl) {
|
|
2984
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2985
|
+
res.end(JSON.stringify({
|
|
2986
|
+
error: "Missing target URL. Set x-cto-target header to the provider API URL.",
|
|
2987
|
+
example: "x-cto-target: https://api.openai.com/v1/chat/completions"
|
|
2988
|
+
}));
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
let targetUrlParsed;
|
|
2992
|
+
try {
|
|
2993
|
+
targetUrlParsed = new URL(targetUrl);
|
|
2994
|
+
} catch {
|
|
2995
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2996
|
+
res.end(JSON.stringify({ error: "Invalid target URL" }));
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
if (targetUrlParsed.protocol !== "https:" && targetUrlParsed.hostname !== "localhost") {
|
|
3000
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
3001
|
+
res.end(JSON.stringify({ error: "Only HTTPS targets allowed (SSRF protection)" }));
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
if (!isAllowedTarget(targetUrlParsed.hostname, this.config)) {
|
|
3005
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
3006
|
+
res.end(JSON.stringify({
|
|
3007
|
+
error: `Target domain not allowed: ${targetUrlParsed.hostname}`,
|
|
3008
|
+
allowed: this.config.allowedTargetDomains.length > 0 ? this.config.allowedTargetDomains : ["api.openai.com", "api.anthropic.com", "*.googleapis.com", "*.openai.azure.com"]
|
|
3009
|
+
}));
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
try {
|
|
3013
|
+
const resolved = await lookup(targetUrlParsed.hostname);
|
|
3014
|
+
if (isPrivateIP(resolved.address)) {
|
|
3015
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
3016
|
+
res.end(JSON.stringify({ error: "Target resolves to private IP (SSRF protection)" }));
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
} catch {
|
|
3020
|
+
}
|
|
3021
|
+
const headers = flattenHeaders(req.headers);
|
|
3022
|
+
const provider = detectProvider(targetUrl || req.url || "", headers);
|
|
3023
|
+
const parsed = provider.parseRequest(parsedBody);
|
|
3024
|
+
const now = /* @__PURE__ */ new Date();
|
|
3025
|
+
if (this.tracker.isDailyBudgetExceeded(now)) {
|
|
3026
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
3027
|
+
res.end(JSON.stringify({
|
|
3028
|
+
error: "Daily budget exceeded",
|
|
3029
|
+
budget: this.config.budgetDaily,
|
|
3030
|
+
current: this.tracker.getDailyCost(now)
|
|
3031
|
+
}));
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
3034
|
+
if (this.tracker.isMonthlyBudgetExceeded(now)) {
|
|
3035
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
3036
|
+
res.end(JSON.stringify({
|
|
3037
|
+
error: "Monthly budget exceeded",
|
|
3038
|
+
budget: this.config.budgetMonthly,
|
|
3039
|
+
current: this.tracker.getMonthlyCost(now)
|
|
3040
|
+
}));
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
if (this.analysisPromise && !this.analysis) {
|
|
3044
|
+
try {
|
|
3045
|
+
await this.analysisPromise;
|
|
3046
|
+
} catch {
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
const interceptResult = await interceptRequest(parsed.messages, this.config, this.analysis);
|
|
3050
|
+
if (interceptResult.secretsBlocked) {
|
|
3051
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
3052
|
+
res.end(JSON.stringify({
|
|
3053
|
+
error: "Request blocked: secrets detected in message content",
|
|
3054
|
+
decisions: interceptResult.decisions,
|
|
3055
|
+
secretsRedacted: interceptResult.secretsRedacted
|
|
3056
|
+
}));
|
|
3057
|
+
this.tracker.record({
|
|
3058
|
+
provider: provider.name,
|
|
3059
|
+
model: parsed.model,
|
|
3060
|
+
inputTokens: 0,
|
|
3061
|
+
outputTokens: 0,
|
|
3062
|
+
costUSD: 0,
|
|
3063
|
+
originalTokens: interceptResult.originalTokens,
|
|
3064
|
+
optimizedTokens: 0,
|
|
3065
|
+
savedTokens: 0,
|
|
3066
|
+
savedUSD: 0,
|
|
3067
|
+
secretsRedacted: interceptResult.secretsRedacted,
|
|
3068
|
+
secretsBlocked: true,
|
|
3069
|
+
projectPath: this.config.projectPath,
|
|
3070
|
+
latencyMs: Date.now() - startTime,
|
|
3071
|
+
stream: parsed.stream,
|
|
3072
|
+
error: "blocked:secrets"
|
|
3073
|
+
});
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
const modifiedBody = rebuildRequestBody(parsedBody, interceptResult.messages, provider.name);
|
|
3077
|
+
try {
|
|
3078
|
+
await this.proxyRequest(targetUrl, req, res, modifiedBody, provider, parsed, interceptResult, startTime);
|
|
3079
|
+
} catch (err) {
|
|
3080
|
+
if (!res.headersSent) {
|
|
3081
|
+
const status = err.message === "upstream-timeout" ? 504 : 502;
|
|
3082
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
3083
|
+
res.end(JSON.stringify({ error: status === 504 ? "Upstream provider timeout" : `Proxy error: ${err.message}` }));
|
|
3084
|
+
}
|
|
3085
|
+
this.emit({ type: "error", message: `Proxy error: ${err.message}`, error: err });
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
// ===== PROXY =====
|
|
3089
|
+
async proxyRequest(targetUrl, clientReq, clientRes, body, provider, parsed, interceptResult, startTime) {
|
|
3090
|
+
const url = new URL(targetUrl);
|
|
3091
|
+
const isHttps = url.protocol === "https:";
|
|
3092
|
+
const requester = isHttps ? httpsRequest : httpRequest;
|
|
3093
|
+
const forwardHeaders = {};
|
|
3094
|
+
const stripHeaders = /* @__PURE__ */ new Set(["host", "content-length", "x-cto-target", "x-target-url", "x-cto-key"]);
|
|
3095
|
+
for (const [key, value] of Object.entries(clientReq.headers)) {
|
|
3096
|
+
if (stripHeaders.has(key)) continue;
|
|
3097
|
+
if (value) forwardHeaders[key] = Array.isArray(value) ? value[0] : value;
|
|
3098
|
+
}
|
|
3099
|
+
forwardHeaders["content-length"] = Buffer.byteLength(body).toString();
|
|
3100
|
+
return new Promise((resolve8, reject) => {
|
|
3101
|
+
const proxyReq = requester(
|
|
3102
|
+
{
|
|
3103
|
+
hostname: url.hostname,
|
|
3104
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
3105
|
+
path: url.pathname + url.search,
|
|
3106
|
+
method: "POST",
|
|
3107
|
+
headers: forwardHeaders,
|
|
3108
|
+
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
|
3109
|
+
// Connection pooling
|
|
3110
|
+
timeout: this.config.upstreamTimeoutMs
|
|
3111
|
+
},
|
|
3112
|
+
(proxyRes) => {
|
|
3113
|
+
if (parsed.stream && proxyRes.headers["content-type"]?.includes("text/event-stream")) {
|
|
3114
|
+
this.handleStreamResponse(
|
|
3115
|
+
proxyRes,
|
|
3116
|
+
clientRes,
|
|
3117
|
+
provider,
|
|
3118
|
+
parsed,
|
|
3119
|
+
interceptResult,
|
|
3120
|
+
startTime
|
|
3121
|
+
).then(resolve8).catch(reject);
|
|
3122
|
+
} else {
|
|
3123
|
+
this.handleBufferedResponse(
|
|
3124
|
+
proxyRes,
|
|
3125
|
+
clientRes,
|
|
3126
|
+
provider,
|
|
3127
|
+
parsed,
|
|
3128
|
+
interceptResult,
|
|
3129
|
+
startTime
|
|
3130
|
+
).then(resolve8).catch(reject);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
);
|
|
3134
|
+
proxyReq.on("timeout", () => {
|
|
3135
|
+
proxyReq.destroy();
|
|
3136
|
+
reject(new Error("upstream-timeout"));
|
|
3137
|
+
});
|
|
3138
|
+
proxyReq.on("error", reject);
|
|
3139
|
+
proxyReq.write(body);
|
|
3140
|
+
proxyReq.end();
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
// ===== STREAM HANDLER =====
|
|
3144
|
+
async handleStreamResponse(proxyRes, clientRes, provider, parsed, interceptResult, startTime) {
|
|
3145
|
+
clientRes.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
3146
|
+
let fullContent2 = "";
|
|
3147
|
+
let inputTokens = 0;
|
|
3148
|
+
let outputTokens = 0;
|
|
3149
|
+
let sseBuffer = "";
|
|
3150
|
+
return new Promise((resolve8) => {
|
|
3151
|
+
proxyRes.on("data", (chunk) => {
|
|
3152
|
+
clientRes.write(chunk);
|
|
3153
|
+
sseBuffer += chunk.toString();
|
|
3154
|
+
const events = sseBuffer.split("\n\n");
|
|
3155
|
+
sseBuffer = events.pop() || "";
|
|
3156
|
+
for (const event of events) {
|
|
3157
|
+
for (const line of event.split("\n")) {
|
|
3158
|
+
if (!line.startsWith("data: ")) continue;
|
|
3159
|
+
const data = line.slice(6).trim();
|
|
3160
|
+
if (data === "[DONE]") continue;
|
|
3161
|
+
try {
|
|
3162
|
+
const obj = JSON.parse(data);
|
|
3163
|
+
const delta = obj.choices?.[0]?.delta?.content || obj.delta?.text || "";
|
|
3164
|
+
if (delta) fullContent2 += delta;
|
|
3165
|
+
if (obj.usage) {
|
|
3166
|
+
inputTokens = obj.usage.prompt_tokens || obj.usage.input_tokens || 0;
|
|
3167
|
+
outputTokens = obj.usage.completion_tokens || obj.usage.output_tokens || 0;
|
|
3168
|
+
}
|
|
3169
|
+
} catch {
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
});
|
|
3174
|
+
proxyRes.on("end", () => {
|
|
3175
|
+
if (sseBuffer.trim()) {
|
|
3176
|
+
for (const line of sseBuffer.split("\n")) {
|
|
3177
|
+
if (!line.startsWith("data: ")) continue;
|
|
3178
|
+
const data = line.slice(6).trim();
|
|
3179
|
+
if (data === "[DONE]") continue;
|
|
3180
|
+
try {
|
|
3181
|
+
const obj = JSON.parse(data);
|
|
3182
|
+
if (obj.usage) {
|
|
3183
|
+
inputTokens = obj.usage.prompt_tokens || obj.usage.input_tokens || 0;
|
|
3184
|
+
outputTokens = obj.usage.completion_tokens || obj.usage.output_tokens || 0;
|
|
3185
|
+
}
|
|
3186
|
+
} catch {
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
clientRes.end();
|
|
3191
|
+
if (inputTokens === 0) inputTokens = interceptResult.optimizedTokens;
|
|
3192
|
+
if (outputTokens === 0) outputTokens = Math.ceil(fullContent2.length / 4);
|
|
3193
|
+
const costUSD = estimateCost(provider, parsed.model, inputTokens, outputTokens);
|
|
3194
|
+
const originalCost = estimateCost(provider, parsed.model, interceptResult.originalTokens, outputTokens);
|
|
3195
|
+
this.tracker.record({
|
|
3196
|
+
provider: provider.name,
|
|
3197
|
+
model: parsed.model,
|
|
3198
|
+
inputTokens,
|
|
3199
|
+
outputTokens,
|
|
3200
|
+
costUSD,
|
|
3201
|
+
originalTokens: interceptResult.originalTokens,
|
|
3202
|
+
optimizedTokens: interceptResult.optimizedTokens,
|
|
3203
|
+
savedTokens: interceptResult.originalTokens - interceptResult.optimizedTokens,
|
|
3204
|
+
savedUSD: Math.max(0, originalCost - costUSD),
|
|
3205
|
+
secretsRedacted: interceptResult.secretsRedacted,
|
|
3206
|
+
secretsBlocked: false,
|
|
3207
|
+
projectPath: this.config.projectPath,
|
|
3208
|
+
latencyMs: Date.now() - startTime,
|
|
3209
|
+
stream: true
|
|
3210
|
+
});
|
|
3211
|
+
resolve8();
|
|
3212
|
+
});
|
|
3213
|
+
proxyRes.on("error", () => {
|
|
3214
|
+
clientRes.end();
|
|
3215
|
+
resolve8();
|
|
3216
|
+
});
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3219
|
+
// ===== BUFFERED HANDLER =====
|
|
3220
|
+
async handleBufferedResponse(proxyRes, clientRes, provider, parsed, interceptResult, startTime) {
|
|
3221
|
+
return new Promise((resolve8) => {
|
|
3222
|
+
const chunks = [];
|
|
3223
|
+
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
3224
|
+
proxyRes.on("end", () => {
|
|
3225
|
+
const responseBody = Buffer.concat(chunks).toString();
|
|
3226
|
+
clientRes.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
3227
|
+
clientRes.end(responseBody);
|
|
3228
|
+
try {
|
|
3229
|
+
const responseJson = JSON.parse(responseBody);
|
|
3230
|
+
const parsedResponse = provider.parseResponse(responseJson, false);
|
|
3231
|
+
const costUSD = estimateCost(
|
|
3232
|
+
provider,
|
|
3233
|
+
parsedResponse.model || parsed.model,
|
|
3234
|
+
parsedResponse.inputTokens,
|
|
3235
|
+
parsedResponse.outputTokens
|
|
3236
|
+
);
|
|
3237
|
+
const originalCost = estimateCost(
|
|
3238
|
+
provider,
|
|
3239
|
+
parsed.model,
|
|
3240
|
+
interceptResult.originalTokens,
|
|
3241
|
+
parsedResponse.outputTokens
|
|
3242
|
+
);
|
|
3243
|
+
this.tracker.record({
|
|
3244
|
+
provider: provider.name,
|
|
3245
|
+
model: parsedResponse.model || parsed.model,
|
|
3246
|
+
inputTokens: parsedResponse.inputTokens,
|
|
3247
|
+
outputTokens: parsedResponse.outputTokens,
|
|
3248
|
+
costUSD,
|
|
3249
|
+
originalTokens: interceptResult.originalTokens,
|
|
3250
|
+
optimizedTokens: interceptResult.optimizedTokens,
|
|
3251
|
+
savedTokens: interceptResult.originalTokens - interceptResult.optimizedTokens,
|
|
3252
|
+
savedUSD: Math.max(0, originalCost - costUSD),
|
|
3253
|
+
secretsRedacted: interceptResult.secretsRedacted,
|
|
3254
|
+
secretsBlocked: false,
|
|
3255
|
+
projectPath: this.config.projectPath,
|
|
3256
|
+
latencyMs: Date.now() - startTime,
|
|
3257
|
+
stream: false
|
|
3258
|
+
});
|
|
3259
|
+
} catch {
|
|
3260
|
+
this.tracker.record({
|
|
3261
|
+
provider: provider.name,
|
|
3262
|
+
model: parsed.model,
|
|
3263
|
+
inputTokens: interceptResult.optimizedTokens,
|
|
3264
|
+
outputTokens: 0,
|
|
3265
|
+
costUSD: 0,
|
|
3266
|
+
originalTokens: interceptResult.originalTokens,
|
|
3267
|
+
optimizedTokens: interceptResult.optimizedTokens,
|
|
3268
|
+
savedTokens: interceptResult.originalTokens - interceptResult.optimizedTokens,
|
|
3269
|
+
savedUSD: 0,
|
|
3270
|
+
secretsRedacted: interceptResult.secretsRedacted,
|
|
3271
|
+
secretsBlocked: false,
|
|
3272
|
+
projectPath: this.config.projectPath,
|
|
3273
|
+
latencyMs: Date.now() - startTime,
|
|
3274
|
+
stream: false,
|
|
3275
|
+
error: "response-parse-failed"
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
resolve8();
|
|
3279
|
+
});
|
|
3280
|
+
proxyRes.on("error", () => {
|
|
3281
|
+
clientRes.end();
|
|
3282
|
+
resolve8();
|
|
3283
|
+
});
|
|
3284
|
+
});
|
|
3285
|
+
}
|
|
3286
|
+
// ===== DASHBOARD =====
|
|
3287
|
+
serveDashboard(_req, res) {
|
|
3288
|
+
const summary = this.tracker.getSummary("month");
|
|
3289
|
+
const dailySummary = this.tracker.getSummary("day");
|
|
3290
|
+
const html = generateDashboardHTML(summary, dailySummary, this.config, this.analysis);
|
|
3291
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
3292
|
+
res.end(html);
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
|
|
3298
|
+
// src/cli/score.ts
|
|
3299
|
+
init_analyzer();
|
|
3300
|
+
import { resolve as resolve7, join as join8 } from "path";
|
|
3301
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, readFileSync as readFileSync4, appendFileSync as appendFileSync2 } from "fs";
|
|
3302
|
+
|
|
3303
|
+
// src/engine/score.ts
|
|
3304
|
+
init_selector();
|
|
3305
|
+
init_coverage();
|
|
3306
|
+
init_graph_utils();
|
|
3307
|
+
async function computeContextScore(analysis, task = "general code review and refactoring", budget = 5e4) {
|
|
3308
|
+
const selection = await selectContext({ task, analysis, budget });
|
|
3309
|
+
const insights = [];
|
|
3310
|
+
const efficiency = scoreEfficiency(analysis, selection, insights);
|
|
3311
|
+
const coverage = scoreCoverage(analysis, selection, insights);
|
|
3312
|
+
const riskControl = scoreRiskControl(analysis, selection, insights);
|
|
3313
|
+
const structure = scoreStructure(analysis, insights);
|
|
3314
|
+
const governance = scoreGovernance(analysis, insights);
|
|
3315
|
+
const overall = Math.round(
|
|
3316
|
+
efficiency.weighted + coverage.weighted + riskControl.weighted + structure.weighted + governance.weighted
|
|
3317
|
+
);
|
|
3318
|
+
const grade = scoreToGrade(overall);
|
|
3319
|
+
const naiveTokens = analysis.totalTokens;
|
|
3320
|
+
const optimizedTokens = selection.totalTokens;
|
|
3321
|
+
const savedTokens = naiveTokens - optimizedTokens;
|
|
3322
|
+
const savedPercent = naiveTokens > 0 ? Math.round(savedTokens / naiveTokens * 100) : 0;
|
|
3323
|
+
const interactionsPerMonth = 40 * 20;
|
|
3324
|
+
const costPerMToken = 3;
|
|
3325
|
+
const naiveMonthlyCost = naiveTokens / 1e6 * costPerMToken * interactionsPerMonth;
|
|
3326
|
+
const optimizedMonthlyCost = optimizedTokens / 1e6 * costPerMToken * interactionsPerMonth;
|
|
3327
|
+
const monthlySavingsUSD = Math.round((naiveMonthlyCost - optimizedMonthlyCost) * 100) / 100;
|
|
3328
|
+
return {
|
|
3329
|
+
overall,
|
|
3330
|
+
grade,
|
|
3331
|
+
dimensions: {
|
|
3332
|
+
efficiency,
|
|
3333
|
+
coverage,
|
|
3334
|
+
riskControl,
|
|
3335
|
+
structure,
|
|
3336
|
+
governance
|
|
3337
|
+
},
|
|
3338
|
+
insights: insights.sort((a, b) => {
|
|
3339
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
3340
|
+
return order[a.impact] - order[b.impact];
|
|
3341
|
+
}),
|
|
3342
|
+
comparison: {
|
|
3343
|
+
naiveTokens,
|
|
3344
|
+
optimizedTokens,
|
|
3345
|
+
savedTokens,
|
|
3346
|
+
savedPercent,
|
|
3347
|
+
monthlySavingsUSD
|
|
3348
|
+
},
|
|
3349
|
+
meta: {
|
|
3350
|
+
projectName: analysis.projectName,
|
|
3351
|
+
totalFiles: analysis.totalFiles,
|
|
3352
|
+
totalTokens: analysis.totalTokens,
|
|
3353
|
+
analyzedAt: analysis.analyzedAt
|
|
3354
|
+
}
|
|
3355
|
+
};
|
|
3356
|
+
}
|
|
3357
|
+
function scoreEfficiency(analysis, selection, insights) {
|
|
3358
|
+
const weight = 30;
|
|
3359
|
+
const ratio = analysis.totalTokens > 0 ? 1 - selection.totalTokens / analysis.totalTokens : 0;
|
|
3360
|
+
const selectivity = analysis.totalFiles > 0 ? 1 - selection.files.length / analysis.totalFiles : 0;
|
|
3361
|
+
const prunedFiles = selection.files.filter(
|
|
3362
|
+
(f) => f.pruneLevel === "signatures" || f.pruneLevel === "skeleton"
|
|
3363
|
+
).length;
|
|
3364
|
+
const pruneRatio = selection.files.length > 0 ? prunedFiles / selection.files.length : 0;
|
|
3365
|
+
const raw = (ratio * 0.5 + selectivity * 0.3 + pruneRatio * 0.2) * 100;
|
|
3366
|
+
const score = Math.min(100, Math.max(0, Math.round(raw)));
|
|
3367
|
+
const weighted = score / 100 * weight;
|
|
3368
|
+
if (ratio > 0.7) {
|
|
3369
|
+
insights.push({
|
|
3370
|
+
type: "strength",
|
|
3371
|
+
title: "Excellent compression",
|
|
3372
|
+
detail: `${Math.round(ratio * 100)}% token reduction while maintaining context quality`,
|
|
3373
|
+
impact: "high"
|
|
3374
|
+
});
|
|
3375
|
+
}
|
|
3376
|
+
if (ratio < 0.3 && analysis.totalTokens > 2e4) {
|
|
3377
|
+
insights.push({
|
|
3378
|
+
type: "weakness",
|
|
3379
|
+
title: "Low compression opportunity",
|
|
3380
|
+
detail: "Most files are needed. Consider splitting the project into smaller modules.",
|
|
3381
|
+
impact: "medium"
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
return {
|
|
3385
|
+
score,
|
|
3386
|
+
weight,
|
|
3387
|
+
weighted,
|
|
3388
|
+
detail: `${Math.round(ratio * 100)}% compression, ${prunedFiles}/${selection.files.length} files pruned`
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
function scoreCoverage(analysis, selection, insights) {
|
|
3392
|
+
const weight = 25;
|
|
1504
3393
|
const coverageScore = selection.coverage.score;
|
|
1505
3394
|
const missingCritical = selection.coverage.missingCritical.length;
|
|
1506
3395
|
let penalty = 0;
|
|
@@ -1727,6 +3616,7 @@ function formatNumber(n) {
|
|
|
1727
3616
|
}
|
|
1728
3617
|
|
|
1729
3618
|
// src/engine/benchmark.ts
|
|
3619
|
+
init_selector();
|
|
1730
3620
|
async function runBenchmark(analysis, task = "general code review and refactoring", budget = 5e4) {
|
|
1731
3621
|
const criticalFiles = analysis.files.filter((f) => f.riskScore >= 80);
|
|
1732
3622
|
const highRiskFiles = analysis.files.filter((f) => f.riskScore >= 60 && f.riskScore < 80);
|
|
@@ -1879,6 +3769,540 @@ function fmt(n) {
|
|
|
1879
3769
|
return n.toString();
|
|
1880
3770
|
}
|
|
1881
3771
|
|
|
3772
|
+
// src/cli/score.ts
|
|
3773
|
+
init_selector();
|
|
3774
|
+
init_secrets();
|
|
3775
|
+
|
|
3776
|
+
// src/engine/monorepo.ts
|
|
3777
|
+
import { readFile as readFile5, readdir as readdir2 } from "fs/promises";
|
|
3778
|
+
import { join as join5, relative as relative4, basename as basename3 } from "path";
|
|
3779
|
+
import { existsSync as existsSync4 } from "fs";
|
|
3780
|
+
async function detectMonorepoTool(rootPath) {
|
|
3781
|
+
const checks = [
|
|
3782
|
+
{ file: "nx.json", tool: "nx" },
|
|
3783
|
+
{ file: "turbo.json", tool: "turborepo" },
|
|
3784
|
+
{ file: "lerna.json", tool: "lerna" },
|
|
3785
|
+
{ file: "pnpm-workspace.yaml", tool: "pnpm-workspaces" },
|
|
3786
|
+
{
|
|
3787
|
+
file: "package.json",
|
|
3788
|
+
tool: "npm-workspaces",
|
|
3789
|
+
validate: (content) => {
|
|
3790
|
+
try {
|
|
3791
|
+
const pkg = JSON.parse(content);
|
|
3792
|
+
return Array.isArray(pkg.workspaces) || typeof pkg.workspaces?.packages !== "undefined";
|
|
3793
|
+
} catch {
|
|
3794
|
+
return false;
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
];
|
|
3799
|
+
for (const check of checks) {
|
|
3800
|
+
const filePath = join5(rootPath, check.file);
|
|
3801
|
+
if (existsSync4(filePath)) {
|
|
3802
|
+
if (!check.validate) return check.tool;
|
|
3803
|
+
try {
|
|
3804
|
+
const content = await readFile5(filePath, "utf-8");
|
|
3805
|
+
if (check.validate(content)) {
|
|
3806
|
+
if (check.tool === "npm-workspaces") {
|
|
3807
|
+
if (existsSync4(join5(rootPath, "yarn.lock"))) return "yarn-workspaces";
|
|
3808
|
+
return "npm-workspaces";
|
|
3809
|
+
}
|
|
3810
|
+
return check.tool;
|
|
3811
|
+
}
|
|
3812
|
+
} catch {
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
return "none";
|
|
3817
|
+
}
|
|
3818
|
+
async function resolveWorkspaceGlobs(rootPath, globs) {
|
|
3819
|
+
const packagePaths = [];
|
|
3820
|
+
for (const glob of globs) {
|
|
3821
|
+
const cleanGlob = glob.replace(/\/?\*\*?$/, "");
|
|
3822
|
+
const searchDir = join5(rootPath, cleanGlob);
|
|
3823
|
+
if (!existsSync4(searchDir)) continue;
|
|
3824
|
+
try {
|
|
3825
|
+
const entries = await readdir2(searchDir, { withFileTypes: true });
|
|
3826
|
+
for (const entry of entries) {
|
|
3827
|
+
if (!entry.isDirectory()) continue;
|
|
3828
|
+
const pkgJsonPath = join5(searchDir, entry.name, "package.json");
|
|
3829
|
+
if (existsSync4(pkgJsonPath)) {
|
|
3830
|
+
packagePaths.push(join5(searchDir, entry.name));
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
} catch {
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
return packagePaths;
|
|
3837
|
+
}
|
|
3838
|
+
async function discoverPackages(rootPath, tool) {
|
|
3839
|
+
switch (tool) {
|
|
3840
|
+
case "npm-workspaces":
|
|
3841
|
+
case "yarn-workspaces": {
|
|
3842
|
+
const pkgJson = JSON.parse(await readFile5(join5(rootPath, "package.json"), "utf-8"));
|
|
3843
|
+
const workspaces = Array.isArray(pkgJson.workspaces) ? pkgJson.workspaces : pkgJson.workspaces?.packages || [];
|
|
3844
|
+
return resolveWorkspaceGlobs(rootPath, workspaces);
|
|
3845
|
+
}
|
|
3846
|
+
case "pnpm-workspaces": {
|
|
3847
|
+
const content = await readFile5(join5(rootPath, "pnpm-workspace.yaml"), "utf-8");
|
|
3848
|
+
const packages = [];
|
|
3849
|
+
let inPackages = false;
|
|
3850
|
+
for (const line of content.split("\n")) {
|
|
3851
|
+
const trimmed = line.trim();
|
|
3852
|
+
if (trimmed === "packages:") {
|
|
3853
|
+
inPackages = true;
|
|
3854
|
+
continue;
|
|
3855
|
+
}
|
|
3856
|
+
if (inPackages && trimmed.startsWith("- ")) {
|
|
3857
|
+
packages.push(trimmed.slice(2).replace(/['"]/g, ""));
|
|
3858
|
+
} else if (inPackages && !trimmed.startsWith("-") && trimmed.length > 0) {
|
|
3859
|
+
inPackages = false;
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
return resolveWorkspaceGlobs(rootPath, packages);
|
|
3863
|
+
}
|
|
3864
|
+
case "turborepo": {
|
|
3865
|
+
const pkgJson = JSON.parse(await readFile5(join5(rootPath, "package.json"), "utf-8"));
|
|
3866
|
+
const workspaces = Array.isArray(pkgJson.workspaces) ? pkgJson.workspaces : pkgJson.workspaces?.packages || [];
|
|
3867
|
+
if (workspaces.length > 0) return resolveWorkspaceGlobs(rootPath, workspaces);
|
|
3868
|
+
if (existsSync4(join5(rootPath, "pnpm-workspace.yaml"))) {
|
|
3869
|
+
return discoverPackages(rootPath, "pnpm-workspaces");
|
|
3870
|
+
}
|
|
3871
|
+
return [];
|
|
3872
|
+
}
|
|
3873
|
+
case "nx": {
|
|
3874
|
+
const standardDirs = ["packages", "apps", "libs"];
|
|
3875
|
+
const globs = standardDirs.filter((d) => existsSync4(join5(rootPath, d)));
|
|
3876
|
+
if (globs.length > 0) return resolveWorkspaceGlobs(rootPath, globs);
|
|
3877
|
+
try {
|
|
3878
|
+
const pkgJson = JSON.parse(await readFile5(join5(rootPath, "package.json"), "utf-8"));
|
|
3879
|
+
const workspaces = Array.isArray(pkgJson.workspaces) ? pkgJson.workspaces : [];
|
|
3880
|
+
if (workspaces.length > 0) return resolveWorkspaceGlobs(rootPath, workspaces);
|
|
3881
|
+
} catch {
|
|
3882
|
+
}
|
|
3883
|
+
return [];
|
|
3884
|
+
}
|
|
3885
|
+
case "lerna": {
|
|
3886
|
+
const lernaJson = JSON.parse(await readFile5(join5(rootPath, "lerna.json"), "utf-8"));
|
|
3887
|
+
const packages = lernaJson.packages || ["packages/*"];
|
|
3888
|
+
return resolveWorkspaceGlobs(rootPath, packages);
|
|
3889
|
+
}
|
|
3890
|
+
default:
|
|
3891
|
+
return [];
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
function buildCrossPackageEdges(packages, allFiles, graphEdges, rootPath) {
|
|
3895
|
+
const fileToPackage = /* @__PURE__ */ new Map();
|
|
3896
|
+
for (const pkg of packages) {
|
|
3897
|
+
const pkgRel = relative4(rootPath, pkg.path);
|
|
3898
|
+
for (const f of allFiles) {
|
|
3899
|
+
if (f.relativePath.startsWith(pkgRel + "/") || f.relativePath.startsWith(pkgRel + "\\")) {
|
|
3900
|
+
fileToPackage.set(f.relativePath, pkg.name);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
const edgeMap = /* @__PURE__ */ new Map();
|
|
3905
|
+
for (const edge of graphEdges) {
|
|
3906
|
+
const fromPkg = fileToPackage.get(edge.from);
|
|
3907
|
+
const toPkg = fileToPackage.get(edge.to);
|
|
3908
|
+
if (fromPkg && toPkg && fromPkg !== toPkg) {
|
|
3909
|
+
const key = `${fromPkg}\u2192${toPkg}`;
|
|
3910
|
+
if (!edgeMap.has(key)) {
|
|
3911
|
+
edgeMap.set(key, { files: /* @__PURE__ */ new Set(), type: "dependency" });
|
|
3912
|
+
}
|
|
3913
|
+
edgeMap.get(key).files.add(edge.from);
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
return Array.from(edgeMap.entries()).map(([key, val]) => {
|
|
3917
|
+
const [from, to] = key.split("\u2192");
|
|
3918
|
+
return { from, to, files: val.files.size, type: val.type };
|
|
3919
|
+
});
|
|
3920
|
+
}
|
|
3921
|
+
async function analyzeMonorepo(rootPath, analysis) {
|
|
3922
|
+
const tool = await detectMonorepoTool(rootPath);
|
|
3923
|
+
if (tool === "none") {
|
|
3924
|
+
return {
|
|
3925
|
+
detected: false,
|
|
3926
|
+
tool: "none",
|
|
3927
|
+
rootPath,
|
|
3928
|
+
packages: [],
|
|
3929
|
+
sharedPackages: [],
|
|
3930
|
+
crossPackageEdges: [],
|
|
3931
|
+
isolationScore: 100,
|
|
3932
|
+
totalTokens: analysis?.totalTokens ?? 0,
|
|
3933
|
+
packageTokenMap: {}
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
const packagePaths = await discoverPackages(rootPath, tool);
|
|
3937
|
+
const packages = [];
|
|
3938
|
+
const packageTokenMap = {};
|
|
3939
|
+
for (const pkgPath of packagePaths) {
|
|
3940
|
+
const pkgJsonPath = join5(pkgPath, "package.json");
|
|
3941
|
+
let name = basename3(pkgPath);
|
|
3942
|
+
let pkgDeps = [];
|
|
3943
|
+
try {
|
|
3944
|
+
const pkgJson = JSON.parse(await readFile5(pkgJsonPath, "utf-8"));
|
|
3945
|
+
name = pkgJson.name || name;
|
|
3946
|
+
const allDeps = {
|
|
3947
|
+
...pkgJson.dependencies || {},
|
|
3948
|
+
...pkgJson.devDependencies || {},
|
|
3949
|
+
...pkgJson.peerDependencies || {}
|
|
3950
|
+
};
|
|
3951
|
+
pkgDeps = Object.keys(allDeps);
|
|
3952
|
+
} catch {
|
|
3953
|
+
}
|
|
3954
|
+
const relPath = relative4(rootPath, pkgPath);
|
|
3955
|
+
let fileCount = 0;
|
|
3956
|
+
let tokenCount = 0;
|
|
3957
|
+
const entryPoints = [];
|
|
3958
|
+
if (analysis) {
|
|
3959
|
+
for (const f of analysis.files) {
|
|
3960
|
+
if (f.relativePath.startsWith(relPath + "/") || f.relativePath.startsWith(relPath + "\\")) {
|
|
3961
|
+
fileCount++;
|
|
3962
|
+
tokenCount += f.tokens;
|
|
3963
|
+
if (f.kind === "entry") entryPoints.push(f.relativePath);
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
packageTokenMap[name] = tokenCount;
|
|
3968
|
+
packages.push({
|
|
3969
|
+
name,
|
|
3970
|
+
path: pkgPath,
|
|
3971
|
+
relativePath: relPath,
|
|
3972
|
+
files: fileCount,
|
|
3973
|
+
tokens: tokenCount,
|
|
3974
|
+
dependencies: [],
|
|
3975
|
+
// Filled below after we know all package names
|
|
3976
|
+
dependents: [],
|
|
3977
|
+
isShared: false,
|
|
3978
|
+
entryPoints
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3981
|
+
const pkgNames = new Set(packages.map((p) => p.name));
|
|
3982
|
+
for (const pkg of packages) {
|
|
3983
|
+
const pkgJsonPath = join5(pkg.path, "package.json");
|
|
3984
|
+
try {
|
|
3985
|
+
const pkgJson = JSON.parse(await readFile5(pkgJsonPath, "utf-8"));
|
|
3986
|
+
const allDeps = {
|
|
3987
|
+
...pkgJson.dependencies || {},
|
|
3988
|
+
...pkgJson.devDependencies || {}
|
|
3989
|
+
};
|
|
3990
|
+
pkg.dependencies = Object.keys(allDeps).filter((d) => pkgNames.has(d));
|
|
3991
|
+
} catch {
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
for (const pkg of packages) {
|
|
3995
|
+
for (const depName of pkg.dependencies) {
|
|
3996
|
+
const dep = packages.find((p) => p.name === depName);
|
|
3997
|
+
if (dep) dep.dependents.push(pkg.name);
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
for (const pkg of packages) {
|
|
4001
|
+
pkg.isShared = pkg.dependents.length >= 2;
|
|
4002
|
+
}
|
|
4003
|
+
const sharedPackages = packages.filter((p) => p.isShared);
|
|
4004
|
+
const crossPackageEdges = analysis ? buildCrossPackageEdges(packages, analysis.files, analysis.graph.edges, rootPath) : [];
|
|
4005
|
+
const maxPossibleEdges = packages.length * (packages.length - 1);
|
|
4006
|
+
const actualEdges = crossPackageEdges.length;
|
|
4007
|
+
const isolationScore = maxPossibleEdges > 0 ? Math.round(100 * (1 - actualEdges / maxPossibleEdges)) : 100;
|
|
4008
|
+
return {
|
|
4009
|
+
detected: true,
|
|
4010
|
+
tool,
|
|
4011
|
+
rootPath,
|
|
4012
|
+
packages,
|
|
4013
|
+
sharedPackages,
|
|
4014
|
+
crossPackageEdges,
|
|
4015
|
+
isolationScore,
|
|
4016
|
+
totalTokens: analysis?.totalTokens ?? packages.reduce((s, p) => s + p.tokens, 0),
|
|
4017
|
+
packageTokenMap
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
4020
|
+
function selectPackageContext(monorepo, targetPackage) {
|
|
4021
|
+
const target = monorepo.packages.find((p) => p.name === targetPackage || p.relativePath === targetPackage);
|
|
4022
|
+
if (!target) {
|
|
4023
|
+
return {
|
|
4024
|
+
targetPackage,
|
|
4025
|
+
includedPackages: [],
|
|
4026
|
+
excludedPackages: monorepo.packages.map((p) => p.name),
|
|
4027
|
+
originalTokens: monorepo.totalTokens,
|
|
4028
|
+
optimizedTokens: 0,
|
|
4029
|
+
savedTokens: monorepo.totalTokens,
|
|
4030
|
+
savedPercent: 100
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
4033
|
+
const includedNames = /* @__PURE__ */ new Set([target.name]);
|
|
4034
|
+
for (const depName of target.dependencies) {
|
|
4035
|
+
includedNames.add(depName);
|
|
4036
|
+
const dep = monorepo.packages.find((p) => p.name === depName);
|
|
4037
|
+
if (dep) {
|
|
4038
|
+
for (const transDep of dep.dependencies) {
|
|
4039
|
+
const transDepPkg = monorepo.packages.find((p) => p.name === transDep);
|
|
4040
|
+
if (transDepPkg?.isShared) includedNames.add(transDep);
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
const includedPackages = Array.from(includedNames);
|
|
4045
|
+
const excludedPackages = monorepo.packages.filter((p) => !includedNames.has(p.name)).map((p) => p.name);
|
|
4046
|
+
const optimizedTokens = monorepo.packages.filter((p) => includedNames.has(p.name)).reduce((s, p) => s + p.tokens, 0);
|
|
4047
|
+
const savedTokens = monorepo.totalTokens - optimizedTokens;
|
|
4048
|
+
const savedPercent = monorepo.totalTokens > 0 ? Math.round(savedTokens / monorepo.totalTokens * 100) : 0;
|
|
4049
|
+
return {
|
|
4050
|
+
targetPackage: target.name,
|
|
4051
|
+
includedPackages,
|
|
4052
|
+
excludedPackages,
|
|
4053
|
+
originalTokens: monorepo.totalTokens,
|
|
4054
|
+
optimizedTokens,
|
|
4055
|
+
savedTokens,
|
|
4056
|
+
savedPercent
|
|
4057
|
+
};
|
|
4058
|
+
}
|
|
4059
|
+
function renderMonorepoAnalysis(mono) {
|
|
4060
|
+
if (!mono.detected) {
|
|
4061
|
+
return " \u2139\uFE0F No monorepo detected (single-package project).\n";
|
|
4062
|
+
}
|
|
4063
|
+
const lines = [];
|
|
4064
|
+
lines.push("");
|
|
4065
|
+
lines.push(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
4066
|
+
lines.push(` \u{1F4E6} Monorepo Analysis \u2014 ${mono.tool}`);
|
|
4067
|
+
lines.push(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
4068
|
+
lines.push("");
|
|
4069
|
+
lines.push(` Packages: ${mono.packages.length}`);
|
|
4070
|
+
lines.push(` Shared packages: ${mono.sharedPackages.length}`);
|
|
4071
|
+
lines.push(` Isolation score: ${mono.isolationScore}/100`);
|
|
4072
|
+
lines.push(` Total tokens: ${mono.totalTokens.toLocaleString()}`);
|
|
4073
|
+
lines.push("");
|
|
4074
|
+
lines.push(" Package breakdown:");
|
|
4075
|
+
lines.push("");
|
|
4076
|
+
const sorted = [...mono.packages].sort((a, b) => b.tokens - a.tokens);
|
|
4077
|
+
const maxNameLen = Math.max(...sorted.map((p) => p.name.length), 10);
|
|
4078
|
+
for (const pkg of sorted) {
|
|
4079
|
+
const name = pkg.name.padEnd(maxNameLen);
|
|
4080
|
+
const tokens = `${(pkg.tokens / 1e3).toFixed(1)}K`.padStart(8);
|
|
4081
|
+
const files = `${pkg.files} files`.padStart(10);
|
|
4082
|
+
const deps = pkg.dependencies.length > 0 ? ` \u2192 ${pkg.dependencies.join(", ")}` : "";
|
|
4083
|
+
const shared = pkg.isShared ? " [shared]" : "";
|
|
4084
|
+
lines.push(` ${name} ${tokens} ${files}${shared}${deps}`);
|
|
4085
|
+
}
|
|
4086
|
+
if (mono.crossPackageEdges.length > 0) {
|
|
4087
|
+
lines.push("");
|
|
4088
|
+
lines.push(" Cross-package dependencies:");
|
|
4089
|
+
for (const edge of mono.crossPackageEdges.slice(0, 10)) {
|
|
4090
|
+
lines.push(` ${edge.from} \u2192 ${edge.to} (${edge.files} files)`);
|
|
4091
|
+
}
|
|
4092
|
+
if (mono.crossPackageEdges.length > 10) {
|
|
4093
|
+
lines.push(` ... and ${mono.crossPackageEdges.length - 10} more`);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
lines.push("");
|
|
4097
|
+
lines.push(" \u{1F4A1} Use --monorepo --package <name> to see context savings for a specific package.");
|
|
4098
|
+
lines.push("");
|
|
4099
|
+
return lines.join("\n");
|
|
4100
|
+
}
|
|
4101
|
+
function renderPackageContext(result) {
|
|
4102
|
+
const lines = [];
|
|
4103
|
+
lines.push("");
|
|
4104
|
+
lines.push(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
4105
|
+
lines.push(` \u{1F3AF} Package Context \u2014 ${result.targetPackage}`);
|
|
4106
|
+
lines.push(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
4107
|
+
lines.push("");
|
|
4108
|
+
lines.push(` Included packages: ${result.includedPackages.length}`);
|
|
4109
|
+
lines.push(` Excluded packages: ${result.excludedPackages.length}`);
|
|
4110
|
+
lines.push("");
|
|
4111
|
+
lines.push(` Original tokens: ${result.originalTokens.toLocaleString()}`);
|
|
4112
|
+
lines.push(` Optimized tokens: ${result.optimizedTokens.toLocaleString()}`);
|
|
4113
|
+
lines.push(` Token savings: ${result.savedTokens.toLocaleString()} (${result.savedPercent}%)`);
|
|
4114
|
+
lines.push("");
|
|
4115
|
+
if (result.includedPackages.length > 0) {
|
|
4116
|
+
lines.push(" \u2705 Included:");
|
|
4117
|
+
for (const p of result.includedPackages) {
|
|
4118
|
+
lines.push(` ${p}`);
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
if (result.excludedPackages.length > 0) {
|
|
4122
|
+
lines.push(" \u2B1C Excluded (not needed for this package):");
|
|
4123
|
+
for (const p of result.excludedPackages.slice(0, 10)) {
|
|
4124
|
+
lines.push(` ${p}`);
|
|
4125
|
+
}
|
|
4126
|
+
if (result.excludedPackages.length > 10) {
|
|
4127
|
+
lines.push(` ... and ${result.excludedPackages.length - 10} more`);
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
lines.push("");
|
|
4131
|
+
return lines.join("\n");
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
// src/engine/quality-gate.ts
|
|
4135
|
+
import { readFile as readFile6, writeFile as writeFile2, mkdir } from "fs/promises";
|
|
4136
|
+
import { resolve as resolve5 } from "path";
|
|
4137
|
+
import { existsSync as existsSync5 } from "fs";
|
|
4138
|
+
var DEFAULT_GATE_CONFIG = {
|
|
4139
|
+
threshold: 70,
|
|
4140
|
+
failOnSecrets: true,
|
|
4141
|
+
failOnRegression: true,
|
|
4142
|
+
regressionLimit: 5,
|
|
4143
|
+
baselinePath: ".cto/baseline.json",
|
|
4144
|
+
secretSeverities: ["critical", "high"]
|
|
4145
|
+
};
|
|
4146
|
+
async function loadBaseline(projectPath, baselinePath) {
|
|
4147
|
+
const filePath = resolve5(projectPath, baselinePath || ".cto/baseline.json");
|
|
4148
|
+
if (!existsSync5(filePath)) return null;
|
|
4149
|
+
try {
|
|
4150
|
+
const content = await readFile6(filePath, "utf-8");
|
|
4151
|
+
return JSON.parse(content);
|
|
4152
|
+
} catch {
|
|
4153
|
+
return null;
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
async function saveBaseline(projectPath, score, commit, branch, baselinePath) {
|
|
4157
|
+
const dir = resolve5(projectPath, ".cto");
|
|
4158
|
+
if (!existsSync5(dir)) await mkdir(dir, { recursive: true });
|
|
4159
|
+
const baseline = {
|
|
4160
|
+
score: score.overall,
|
|
4161
|
+
grade: score.grade,
|
|
4162
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4163
|
+
commit,
|
|
4164
|
+
branch,
|
|
4165
|
+
dimensions: {
|
|
4166
|
+
efficiency: score.dimensions.efficiency.score,
|
|
4167
|
+
coverage: score.dimensions.coverage.score,
|
|
4168
|
+
riskControl: score.dimensions.riskControl.score,
|
|
4169
|
+
structure: score.dimensions.structure.score,
|
|
4170
|
+
governance: score.dimensions.governance.score
|
|
4171
|
+
}
|
|
4172
|
+
};
|
|
4173
|
+
const filePath = resolve5(projectPath, baselinePath || ".cto/baseline.json");
|
|
4174
|
+
await writeFile2(filePath, JSON.stringify(baseline, null, 2));
|
|
4175
|
+
}
|
|
4176
|
+
async function runQualityGate(score, analysis, secretFindings, config = {}) {
|
|
4177
|
+
const cfg = { ...DEFAULT_GATE_CONFIG, ...config };
|
|
4178
|
+
const checks = [];
|
|
4179
|
+
const baseline = await loadBaseline(analysis.projectPath, cfg.baselinePath);
|
|
4180
|
+
const previousScore = baseline?.score ?? null;
|
|
4181
|
+
const delta = previousScore !== null ? score.overall - previousScore : null;
|
|
4182
|
+
const thresholdPassed = score.overall >= cfg.threshold;
|
|
4183
|
+
checks.push({
|
|
4184
|
+
name: "Score threshold",
|
|
4185
|
+
passed: thresholdPassed,
|
|
4186
|
+
detail: thresholdPassed ? `Score ${score.overall} \u2265 threshold ${cfg.threshold}` : `Score ${score.overall} < threshold ${cfg.threshold}`,
|
|
4187
|
+
severity: thresholdPassed ? "info" : "error"
|
|
4188
|
+
});
|
|
4189
|
+
const dangerousSecrets = secretFindings.filter(
|
|
4190
|
+
(f) => cfg.secretSeverities.includes(f.severity)
|
|
4191
|
+
);
|
|
4192
|
+
const secretsPassed = !cfg.failOnSecrets || dangerousSecrets.length === 0;
|
|
4193
|
+
checks.push({
|
|
4194
|
+
name: "No secrets detected",
|
|
4195
|
+
passed: secretsPassed,
|
|
4196
|
+
detail: secretsPassed ? "No critical/high severity secrets found" : `${dangerousSecrets.length} secret(s) with ${cfg.secretSeverities.join("/")} severity`,
|
|
4197
|
+
severity: secretsPassed ? "info" : "error"
|
|
4198
|
+
});
|
|
4199
|
+
let regressionPassed = true;
|
|
4200
|
+
if (cfg.failOnRegression && delta !== null) {
|
|
4201
|
+
regressionPassed = delta >= -cfg.regressionLimit;
|
|
4202
|
+
}
|
|
4203
|
+
checks.push({
|
|
4204
|
+
name: "No score regression",
|
|
4205
|
+
passed: regressionPassed,
|
|
4206
|
+
detail: delta !== null ? regressionPassed ? `Score changed by ${delta >= 0 ? "+" : ""}${delta} (limit: -${cfg.regressionLimit})` : `Score dropped by ${Math.abs(delta)} points (limit: -${cfg.regressionLimit})` : "No baseline found (first run)",
|
|
4207
|
+
severity: regressionPassed ? delta !== null && delta < 0 ? "warning" : "info" : "error"
|
|
4208
|
+
});
|
|
4209
|
+
const weakDimensions = Object.entries(score.dimensions).filter(([_, d]) => d.score < 50).map(([name]) => name);
|
|
4210
|
+
const dimensionsPassed = weakDimensions.length === 0;
|
|
4211
|
+
checks.push({
|
|
4212
|
+
name: "Dimension health",
|
|
4213
|
+
passed: dimensionsPassed,
|
|
4214
|
+
detail: dimensionsPassed ? "All dimensions above 50%" : `Weak dimensions: ${weakDimensions.join(", ")}`,
|
|
4215
|
+
severity: dimensionsPassed ? "info" : "warning"
|
|
4216
|
+
});
|
|
4217
|
+
const passed = checks.filter((c) => c.severity === "error").every((c) => c.passed);
|
|
4218
|
+
const prComment = generatePRComment(score, analysis, checks, baseline, delta);
|
|
4219
|
+
const summary = generateSummary(score, checks, passed);
|
|
4220
|
+
return {
|
|
4221
|
+
passed,
|
|
4222
|
+
score: score.overall,
|
|
4223
|
+
grade: score.grade,
|
|
4224
|
+
previousScore,
|
|
4225
|
+
delta,
|
|
4226
|
+
checks,
|
|
4227
|
+
baseline,
|
|
4228
|
+
prComment,
|
|
4229
|
+
summary
|
|
4230
|
+
};
|
|
4231
|
+
}
|
|
4232
|
+
function generatePRComment(score, analysis, checks, baseline, delta) {
|
|
4233
|
+
const gradeEmoji = score.grade.startsWith("A") ? "\u{1F7E2}" : score.grade.startsWith("B") ? "\u{1F535}" : score.grade.startsWith("C") ? "\u{1F7E1}" : "\u{1F534}";
|
|
4234
|
+
const allPassed = checks.filter((c) => c.severity === "error").every((c) => c.passed);
|
|
4235
|
+
const statusIcon = allPassed ? "\u2705" : "\u274C";
|
|
4236
|
+
const deltaStr = delta !== null ? ` (${delta >= 0 ? "+" : ""}${delta})` : "";
|
|
4237
|
+
const lines = [
|
|
4238
|
+
`## ${statusIcon} CTO Quality Gate ${allPassed ? "Passed" : "Failed"}`,
|
|
4239
|
+
"",
|
|
4240
|
+
`### ${gradeEmoji} Context Score: ${score.overall}/100 (${score.grade})${deltaStr}`,
|
|
4241
|
+
"",
|
|
4242
|
+
`> **${analysis.projectName}** \xB7 ${analysis.totalFiles} files \xB7 ${Math.round(analysis.totalTokens / 1e3)}K tokens`,
|
|
4243
|
+
"",
|
|
4244
|
+
"### Checks",
|
|
4245
|
+
"",
|
|
4246
|
+
"| Check | Status | Detail |",
|
|
4247
|
+
"|-------|--------|--------|"
|
|
4248
|
+
];
|
|
4249
|
+
for (const check of checks) {
|
|
4250
|
+
const icon = check.passed ? "\u2705" : check.severity === "warning" ? "\u26A0\uFE0F" : "\u274C";
|
|
4251
|
+
lines.push(`| ${check.name} | ${icon} | ${check.detail} |`);
|
|
4252
|
+
}
|
|
4253
|
+
lines.push("");
|
|
4254
|
+
lines.push("### Dimensions");
|
|
4255
|
+
lines.push("");
|
|
4256
|
+
lines.push("| Dimension | Score | vs Baseline |");
|
|
4257
|
+
lines.push("|-----------|-------|-------------|");
|
|
4258
|
+
for (const [name, dim] of Object.entries(score.dimensions)) {
|
|
4259
|
+
const prev = baseline?.dimensions[name];
|
|
4260
|
+
const diff = prev !== void 0 ? dim.score - prev : null;
|
|
4261
|
+
const diffStr = diff !== null ? `${diff >= 0 ? "+" : ""}${diff}` : "\u2014";
|
|
4262
|
+
const bar = renderBar2(dim.score);
|
|
4263
|
+
lines.push(`| ${name} | ${bar} ${dim.score}% | ${diffStr} |`);
|
|
4264
|
+
}
|
|
4265
|
+
lines.push("");
|
|
4266
|
+
lines.push("### Savings");
|
|
4267
|
+
lines.push("");
|
|
4268
|
+
lines.push(`| Metric | Value |`);
|
|
4269
|
+
lines.push(`|--------|-------|`);
|
|
4270
|
+
lines.push(`| Tokens saved | ${score.comparison.savedTokens.toLocaleString()} (${score.comparison.savedPercent}%) |`);
|
|
4271
|
+
lines.push(`| Monthly savings | $${score.comparison.monthlySavingsUSD.toFixed(2)} |`);
|
|
4272
|
+
if (score.insights.length > 0) {
|
|
4273
|
+
lines.push("");
|
|
4274
|
+
lines.push("### Insights");
|
|
4275
|
+
lines.push("");
|
|
4276
|
+
for (const insight of score.insights.slice(0, 5)) {
|
|
4277
|
+
const icon = insight.type === "strength" ? "\u2705" : insight.type === "weakness" ? "\u26A0\uFE0F" : "\u{1F4A1}";
|
|
4278
|
+
lines.push(`- ${icon} **${insight.title}** \u2014 ${insight.detail}`);
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
lines.push("");
|
|
4282
|
+
lines.push("---");
|
|
4283
|
+
lines.push(`<sub>Generated by [CTO Quality Gate](https://npmjs.com/package/cto-ai-cli) \xB7 ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}</sub>`);
|
|
4284
|
+
return lines.join("\n");
|
|
4285
|
+
}
|
|
4286
|
+
function renderBar2(score) {
|
|
4287
|
+
const filled = Math.round(score / 10);
|
|
4288
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
|
|
4289
|
+
}
|
|
4290
|
+
function generateSummary(score, checks, passed) {
|
|
4291
|
+
const status = passed ? "\u2705 PASSED" : "\u274C FAILED";
|
|
4292
|
+
const failedChecks = checks.filter((c) => !c.passed && c.severity === "error");
|
|
4293
|
+
const warnings = checks.filter((c) => !c.passed && c.severity === "warning");
|
|
4294
|
+
let summary = `Quality Gate ${status} \u2014 Score: ${score.overall}/100 (${score.grade})`;
|
|
4295
|
+
if (failedChecks.length > 0) {
|
|
4296
|
+
summary += `
|
|
4297
|
+
Failed: ${failedChecks.map((c) => c.name).join(", ")}`;
|
|
4298
|
+
}
|
|
4299
|
+
if (warnings.length > 0) {
|
|
4300
|
+
summary += `
|
|
4301
|
+
Warnings: ${warnings.map((c) => c.name).join(", ")}`;
|
|
4302
|
+
}
|
|
4303
|
+
return summary;
|
|
4304
|
+
}
|
|
4305
|
+
|
|
1882
4306
|
// src/cli/score.ts
|
|
1883
4307
|
async function main() {
|
|
1884
4308
|
const args = process.argv.slice(2);
|
|
@@ -1887,24 +4311,60 @@ async function main() {
|
|
|
1887
4311
|
const fixMode = args.includes("--fix");
|
|
1888
4312
|
const reportMode = args.includes("--report");
|
|
1889
4313
|
const compareMode = args.includes("--compare");
|
|
4314
|
+
const auditMode = args.includes("--audit");
|
|
4315
|
+
const initHookMode = args.includes("--init-hook");
|
|
4316
|
+
const fullScanMode = args.includes("--full-scan");
|
|
4317
|
+
const noAllowlistMode = args.includes("--no-allowlist");
|
|
4318
|
+
const monorepoMode = args.includes("--monorepo");
|
|
4319
|
+
const gatewayMode = args.includes("--gateway");
|
|
4320
|
+
const ciMode = args.includes("--ci");
|
|
1890
4321
|
const helpMode = args.includes("--help") || args.includes("-h");
|
|
4322
|
+
const pkgIdx = args.indexOf("--package");
|
|
4323
|
+
const targetPackage = pkgIdx !== -1 && args[pkgIdx + 1] ? args[pkgIdx + 1] : null;
|
|
4324
|
+
const threshIdx = args.indexOf("--threshold");
|
|
4325
|
+
const thresholdArg = threshIdx !== -1 && args[threshIdx + 1] ? parseInt(args[threshIdx + 1], 10) : 70;
|
|
1891
4326
|
const contextIdx = args.indexOf("--context");
|
|
1892
4327
|
const contextTask = contextIdx !== -1 && args[contextIdx + 1] ? args[contextIdx + 1] : null;
|
|
1893
|
-
const pathArg = args.find((a) => !a.startsWith("--") && !a.startsWith("-") && a !== contextTask);
|
|
1894
|
-
const projectPath =
|
|
4328
|
+
const pathArg = args.find((a) => !a.startsWith("--") && !a.startsWith("-") && a !== contextTask && a !== targetPackage);
|
|
4329
|
+
const projectPath = resolve7(pathArg ?? ".");
|
|
1895
4330
|
if (helpMode) {
|
|
1896
4331
|
console.log(`
|
|
1897
4332
|
\u26A1 cto-score \u2014 How AI-ready is your codebase?
|
|
1898
4333
|
|
|
1899
4334
|
Usage:
|
|
1900
|
-
npx cto-ai-cli
|
|
1901
|
-
npx cto-ai-cli ./path
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
npx cto-ai-cli --
|
|
1905
|
-
npx cto-ai-cli --
|
|
1906
|
-
npx cto-ai-cli --
|
|
1907
|
-
npx cto-ai-cli --
|
|
4335
|
+
npx cto-ai-cli Scan current directory
|
|
4336
|
+
npx cto-ai-cli ./path Scan a specific project
|
|
4337
|
+
|
|
4338
|
+
Phase 1 \u2014 Marketing:
|
|
4339
|
+
npx cto-ai-cli --benchmark CTO vs naive vs random comparison
|
|
4340
|
+
npx cto-ai-cli --fix Auto-generate optimized context files
|
|
4341
|
+
npx cto-ai-cli --context "your task" Generate task-specific context
|
|
4342
|
+
npx cto-ai-cli --report Generate shareable markdown report
|
|
4343
|
+
npx cto-ai-cli --compare Compare your score vs popular projects
|
|
4344
|
+
|
|
4345
|
+
Phase 2 \u2014 Security:
|
|
4346
|
+
npx cto-ai-cli --audit Security audit: detect secrets & PII
|
|
4347
|
+
npx cto-ai-cli --audit --init-hook Generate pre-commit hook
|
|
4348
|
+
npx cto-ai-cli --audit --full-scan Skip incremental cache
|
|
4349
|
+
npx cto-ai-cli --audit --no-allowlist Ignore allowlist
|
|
4350
|
+
|
|
4351
|
+
Phase 3 \u2014 Gateway:
|
|
4352
|
+
npx cto-ai-cli --gateway Start Context Gateway (proxy)
|
|
4353
|
+
npx cto-ai-cli --gateway --port 9000 Custom port
|
|
4354
|
+
npx cto-ai-cli --gateway --block-secrets Block requests with secrets
|
|
4355
|
+
npx cto-ai-cli --gateway --budget-daily 10 Daily budget ($10/day)
|
|
4356
|
+
|
|
4357
|
+
Phase 4 \u2014 Monorepo:
|
|
4358
|
+
npx cto-ai-cli --monorepo Analyze monorepo structure
|
|
4359
|
+
npx cto-ai-cli --monorepo --package <name> Context savings for a package
|
|
4360
|
+
|
|
4361
|
+
Phase 5 \u2014 CI/CD Quality Gate:
|
|
4362
|
+
npx cto-ai-cli --ci Run quality gate (exits 1 on failure)
|
|
4363
|
+
npx cto-ai-cli --ci --threshold 80 Set minimum score (default: 70)
|
|
4364
|
+
npx cto-ai-cli --ci --json JSON output for CI pipelines
|
|
4365
|
+
|
|
4366
|
+
Options:
|
|
4367
|
+
npx cto-ai-cli --json Output as JSON (for CI/scripts)
|
|
1908
4368
|
|
|
1909
4369
|
What it does:
|
|
1910
4370
|
Analyzes your project's structure, dependencies, and risk profile.
|
|
@@ -1916,6 +4376,65 @@ async function main() {
|
|
|
1916
4376
|
`);
|
|
1917
4377
|
process.exit(0);
|
|
1918
4378
|
}
|
|
4379
|
+
if (gatewayMode) {
|
|
4380
|
+
const { ContextGateway: ContextGateway2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|
|
4381
|
+
const { DEFAULT_GATEWAY_CONFIG: DEFAULT_GATEWAY_CONFIG2 } = await Promise.resolve().then(() => (init_types(), types_exports));
|
|
4382
|
+
const getArg = (flag) => {
|
|
4383
|
+
const idx = args.indexOf(flag);
|
|
4384
|
+
return idx !== -1 && args[idx + 1] ? args[idx + 1] : void 0;
|
|
4385
|
+
};
|
|
4386
|
+
const gwConfig = { projectPath };
|
|
4387
|
+
const port = getArg("--port");
|
|
4388
|
+
if (port) gwConfig.port = parseInt(port, 10);
|
|
4389
|
+
const budgetDaily = getArg("--budget-daily");
|
|
4390
|
+
if (budgetDaily) gwConfig.budgetDaily = parseFloat(budgetDaily);
|
|
4391
|
+
const budgetMonthly = getArg("--budget-monthly");
|
|
4392
|
+
if (budgetMonthly) gwConfig.budgetMonthly = parseFloat(budgetMonthly);
|
|
4393
|
+
const apiKey = getArg("--api-key");
|
|
4394
|
+
if (apiKey) gwConfig.apiKey = apiKey;
|
|
4395
|
+
if (args.includes("--block-secrets")) gwConfig.blockOnSecrets = true;
|
|
4396
|
+
if (args.includes("--no-optimize")) gwConfig.optimize = false;
|
|
4397
|
+
if (args.includes("--no-redact")) gwConfig.redactSecrets = false;
|
|
4398
|
+
if (args.includes("--no-dashboard")) gwConfig.dashboard = false;
|
|
4399
|
+
const finalConfig = { ...DEFAULT_GATEWAY_CONFIG2, ...gwConfig };
|
|
4400
|
+
const gateway = new ContextGateway2(finalConfig);
|
|
4401
|
+
gateway.onEvent((event) => {
|
|
4402
|
+
const ts = (/* @__PURE__ */ new Date()).toLocaleTimeString();
|
|
4403
|
+
switch (event.type) {
|
|
4404
|
+
case "request": {
|
|
4405
|
+
const r = event.record;
|
|
4406
|
+
const saved = r.savedTokens > 0 ? ` (saved ${(r.savedTokens / 1e3).toFixed(1)}K)` : "";
|
|
4407
|
+
const secrets = r.secretsRedacted > 0 ? ` [${r.secretsRedacted} redacted]` : "";
|
|
4408
|
+
console.log(` ${ts} ${r.provider}/${r.model} $${r.costUSD.toFixed(4)}${saved}${secrets} ${r.latencyMs}ms`);
|
|
4409
|
+
break;
|
|
4410
|
+
}
|
|
4411
|
+
case "budget-alert":
|
|
4412
|
+
console.log(` \u26A0\uFE0F ${ts} Budget alert: $${event.current.toFixed(2)}/$${event.limit.toFixed(2)} (${event.period})`);
|
|
4413
|
+
break;
|
|
4414
|
+
case "budget-exceeded":
|
|
4415
|
+
console.log(` \u{1F534} ${ts} Budget EXCEEDED: $${event.current.toFixed(2)}/$${event.limit.toFixed(2)} (${event.period})`);
|
|
4416
|
+
break;
|
|
4417
|
+
case "error":
|
|
4418
|
+
console.log(` \u274C ${ts} ${event.message}`);
|
|
4419
|
+
break;
|
|
4420
|
+
}
|
|
4421
|
+
});
|
|
4422
|
+
console.log("");
|
|
4423
|
+
console.log(" \u26A1 CTO Context Gateway v4.1.0");
|
|
4424
|
+
console.log("");
|
|
4425
|
+
await gateway.start();
|
|
4426
|
+
console.log(` \u{1F310} Proxy: http://${finalConfig.host}:${finalConfig.port}`);
|
|
4427
|
+
if (finalConfig.dashboard) {
|
|
4428
|
+
console.log(` \u{1F4CA} Dashboard: http://${finalConfig.host}:${finalConfig.port}${finalConfig.dashboardPath}`);
|
|
4429
|
+
}
|
|
4430
|
+
console.log(` \u{1F4C1} Project: ${finalConfig.projectPath}`);
|
|
4431
|
+
console.log("");
|
|
4432
|
+
console.log(" Waiting for requests... (Ctrl+C to stop)");
|
|
4433
|
+
console.log("");
|
|
4434
|
+
await new Promise(() => {
|
|
4435
|
+
});
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
1919
4438
|
console.log("");
|
|
1920
4439
|
console.log(" \u26A1 cto-score \u2014 analyzing your project...");
|
|
1921
4440
|
console.log("");
|
|
@@ -1964,6 +4483,15 @@ async function main() {
|
|
|
1964
4483
|
if (compareMode) {
|
|
1965
4484
|
runCompare(score);
|
|
1966
4485
|
}
|
|
4486
|
+
if (auditMode) {
|
|
4487
|
+
await runAudit(projectPath, analysis, { initHookMode, fullScanMode, noAllowlistMode });
|
|
4488
|
+
}
|
|
4489
|
+
if (monorepoMode) {
|
|
4490
|
+
await runMonorepo(projectPath, analysis, targetPackage, jsonMode);
|
|
4491
|
+
}
|
|
4492
|
+
if (ciMode) {
|
|
4493
|
+
await runCIGate(projectPath, analysis, score, thresholdArg, jsonMode);
|
|
4494
|
+
}
|
|
1967
4495
|
console.log("");
|
|
1968
4496
|
console.log(` Scanned in ${elapsed}s \xB7 ${analysis.totalFiles} files \xB7 ${Math.round(analysis.totalTokens / 1e3)}K tokens`);
|
|
1969
4497
|
console.log("");
|
|
@@ -1993,8 +4521,8 @@ async function main() {
|
|
|
1993
4521
|
}
|
|
1994
4522
|
}
|
|
1995
4523
|
async function runFix(projectPath, analysis, score) {
|
|
1996
|
-
const ctoDir =
|
|
1997
|
-
|
|
4524
|
+
const ctoDir = join8(projectPath, ".cto");
|
|
4525
|
+
mkdirSync3(ctoDir, { recursive: true });
|
|
1998
4526
|
const selection = await selectContext({
|
|
1999
4527
|
task: "general code review and refactoring",
|
|
2000
4528
|
analysis,
|
|
@@ -2052,7 +4580,7 @@ async function runFix(projectPath, analysis, score) {
|
|
|
2052
4580
|
`;
|
|
2053
4581
|
contextMd += `## Savings: ${score.comparison.savedPercent}% (${formatTokens(score.comparison.savedTokens)})
|
|
2054
4582
|
`;
|
|
2055
|
-
|
|
4583
|
+
writeFileSync2(join8(ctoDir, "context.md"), contextMd);
|
|
2056
4584
|
const config = {
|
|
2057
4585
|
version: "3.0",
|
|
2058
4586
|
project: analysis.projectName,
|
|
@@ -2080,7 +4608,7 @@ async function runFix(projectPath, analysis, score) {
|
|
|
2080
4608
|
impact: i.impact
|
|
2081
4609
|
}))
|
|
2082
4610
|
};
|
|
2083
|
-
|
|
4611
|
+
writeFileSync2(join8(ctoDir, "config.json"), JSON.stringify(config, null, 2));
|
|
2084
4612
|
const ignoreContent = [
|
|
2085
4613
|
"# CTO AI-ignore \u2014 files that add noise to AI context",
|
|
2086
4614
|
"# Generated by cto-ai-cli",
|
|
@@ -2106,7 +4634,7 @@ async function runFix(projectPath, analysis, score) {
|
|
|
2106
4634
|
}).slice(0, 20).map((o) => `${o} # orphan, low-risk`),
|
|
2107
4635
|
""
|
|
2108
4636
|
].join("\n");
|
|
2109
|
-
|
|
4637
|
+
writeFileSync2(join8(ctoDir, ".cteignore"), ignoreContent);
|
|
2110
4638
|
console.log("");
|
|
2111
4639
|
console.log(" \u2705 Auto-fix complete! Generated:");
|
|
2112
4640
|
console.log("");
|
|
@@ -2119,8 +4647,8 @@ async function runFix(projectPath, analysis, score) {
|
|
|
2119
4647
|
console.log("");
|
|
2120
4648
|
}
|
|
2121
4649
|
async function runContext(projectPath, analysis, task) {
|
|
2122
|
-
const ctoDir =
|
|
2123
|
-
|
|
4650
|
+
const ctoDir = join8(projectPath, ".cto");
|
|
4651
|
+
mkdirSync3(ctoDir, { recursive: true });
|
|
2124
4652
|
const selection = await selectContext({
|
|
2125
4653
|
task,
|
|
2126
4654
|
analysis,
|
|
@@ -2200,12 +4728,14 @@ async function runContext(projectPath, analysis, task) {
|
|
|
2200
4728
|
const fullFile = analysis.files.find((f) => f.relativePath === sf.relativePath);
|
|
2201
4729
|
if (!fullFile) continue;
|
|
2202
4730
|
try {
|
|
2203
|
-
const content =
|
|
4731
|
+
const content = readFileSync4(fullFile.path, "utf-8");
|
|
2204
4732
|
const ext = fullFile.extension.replace(".", "");
|
|
2205
4733
|
contextMd += `### ${sf.relativePath}
|
|
2206
4734
|
`;
|
|
4735
|
+
const maxChars = 5e3;
|
|
4736
|
+
const truncated = content.length > maxChars;
|
|
2207
4737
|
contextMd += `\`\`\`${ext}
|
|
2208
|
-
${content.slice(0,
|
|
4738
|
+
${content.slice(0, maxChars)}${truncated ? "\n// ... [truncated \u2014 " + (content.length - maxChars) + " chars omitted]" : ""}
|
|
2209
4739
|
\`\`\`
|
|
2210
4740
|
|
|
2211
4741
|
`;
|
|
@@ -2214,7 +4744,7 @@ ${content.slice(0, 5e3)}
|
|
|
2214
4744
|
}
|
|
2215
4745
|
const safeName = task.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 40);
|
|
2216
4746
|
const filename = `context-${safeName}.md`;
|
|
2217
|
-
|
|
4747
|
+
writeFileSync2(join8(ctoDir, filename), contextMd);
|
|
2218
4748
|
console.log("");
|
|
2219
4749
|
console.log(` \u2705 Task context generated!`);
|
|
2220
4750
|
console.log("");
|
|
@@ -2232,9 +4762,10 @@ async function runReport(projectPath, analysis, score) {
|
|
|
2232
4762
|
let report = `# CTO Context Score\u2122 Report
|
|
2233
4763
|
|
|
2234
4764
|
`;
|
|
4765
|
+
const safeGrade = encodeURIComponent(score.grade);
|
|
2235
4766
|
report += `
|
|
2236
4767
|
`;
|
|
2237
|
-
report += `
|
|
2238
4769
|
|
|
2239
4770
|
`;
|
|
2240
4771
|
report += `> Generated by [cto-ai-cli](https://npmjs.com/package/cto-ai-cli) on ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
|
|
@@ -2306,9 +4837,9 @@ async function runReport(projectPath, analysis, score) {
|
|
|
2306
4837
|
`;
|
|
2307
4838
|
report += `*Run \`npx cto-ai-cli\` to generate your own report. [Learn more](https://npmjs.com/package/cto-ai-cli)*
|
|
2308
4839
|
`;
|
|
2309
|
-
const ctoDir =
|
|
2310
|
-
|
|
2311
|
-
|
|
4840
|
+
const ctoDir = join8(projectPath, ".cto");
|
|
4841
|
+
mkdirSync3(ctoDir, { recursive: true });
|
|
4842
|
+
writeFileSync2(join8(ctoDir, "report.md"), report);
|
|
2312
4843
|
console.log("");
|
|
2313
4844
|
console.log(" \u2705 Report generated!");
|
|
2314
4845
|
console.log("");
|
|
@@ -2365,9 +4896,256 @@ function renderCompareBar(pct) {
|
|
|
2365
4896
|
const empty = width - filled;
|
|
2366
4897
|
return "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
2367
4898
|
}
|
|
4899
|
+
async function runAudit(projectPath, analysis, flags = {}) {
|
|
4900
|
+
if (flags.initHookMode) {
|
|
4901
|
+
const { generatePreCommitHook: generatePreCommitHook2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
|
|
4902
|
+
const hookPath = generatePreCommitHook2(projectPath, "husky");
|
|
4903
|
+
console.log("");
|
|
4904
|
+
console.log(" \u2705 Pre-commit hook generated!");
|
|
4905
|
+
console.log(` \u{1F4CB} ${hookPath}`);
|
|
4906
|
+
console.log("");
|
|
4907
|
+
console.log(" Staged files will be scanned for secrets before every commit.");
|
|
4908
|
+
console.log(" To remove: delete the hook file.");
|
|
4909
|
+
console.log("");
|
|
4910
|
+
return;
|
|
4911
|
+
}
|
|
4912
|
+
console.log("");
|
|
4913
|
+
console.log(" \u{1F50D} Running security audit...");
|
|
4914
|
+
console.log("");
|
|
4915
|
+
const filePaths = analysis.files.map((f) => f.path);
|
|
4916
|
+
const result = await auditProject(projectPath, filePaths, {
|
|
4917
|
+
includePII: true,
|
|
4918
|
+
incrementalScan: !flags.fullScanMode,
|
|
4919
|
+
useAllowlist: !flags.noAllowlistMode
|
|
4920
|
+
});
|
|
4921
|
+
const { summary, findings, recommendations } = result;
|
|
4922
|
+
const statusIcon = summary.bySeverity.critical > 0 ? "\u{1F534}" : summary.bySeverity.high > 0 ? "\u{1F7E0}" : summary.totalFindings > 0 ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
4923
|
+
const statusText = summary.bySeverity.critical > 0 ? "CRITICAL ISSUES FOUND" : summary.bySeverity.high > 0 ? "HIGH-SEVERITY ISSUES FOUND" : summary.totalFindings > 0 ? "MINOR ISSUES FOUND" : "ALL CLEAR";
|
|
4924
|
+
console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
|
|
4925
|
+
console.log(" \u2551 \u2551");
|
|
4926
|
+
console.log(` \u2551 ${statusIcon} Security Audit: ${statusText.padEnd(28)} \u2551`);
|
|
4927
|
+
console.log(" \u2551 \u2551");
|
|
4928
|
+
console.log(` \u2551 Files scanned: ${summary.filesScanned.toString().padEnd(30)} \u2551`);
|
|
4929
|
+
console.log(` \u2551 Files affected: ${summary.filesWithSecrets.toString().padEnd(30)} \u2551`);
|
|
4930
|
+
console.log(` \u2551 Total findings: ${summary.totalFindings.toString().padEnd(30)} \u2551`);
|
|
4931
|
+
console.log(" \u2551 \u2551");
|
|
4932
|
+
console.log(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563");
|
|
4933
|
+
console.log(" \u2551 \u2551");
|
|
4934
|
+
if (summary.bySeverity.critical > 0) {
|
|
4935
|
+
console.log(` \u2551 \u{1F534} Critical: ${summary.bySeverity.critical.toString().padEnd(33)} \u2551`);
|
|
4936
|
+
}
|
|
4937
|
+
if (summary.bySeverity.high > 0) {
|
|
4938
|
+
console.log(` \u2551 \u{1F7E0} High: ${summary.bySeverity.high.toString().padEnd(33)} \u2551`);
|
|
4939
|
+
}
|
|
4940
|
+
if (summary.bySeverity.medium > 0) {
|
|
4941
|
+
console.log(` \u2551 \u{1F7E1} Medium: ${summary.bySeverity.medium.toString().padEnd(33)} \u2551`);
|
|
4942
|
+
}
|
|
4943
|
+
if (summary.bySeverity.low > 0) {
|
|
4944
|
+
console.log(` \u2551 \u{1F535} Low: ${summary.bySeverity.low.toString().padEnd(33)} \u2551`);
|
|
4945
|
+
}
|
|
4946
|
+
if (summary.totalFindings === 0) {
|
|
4947
|
+
console.log(" \u2551 \u2705 No secrets or PII detected \u2551");
|
|
4948
|
+
}
|
|
4949
|
+
console.log(" \u2551 \u2551");
|
|
4950
|
+
if (Object.keys(summary.byType).length > 0) {
|
|
4951
|
+
console.log(" \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563");
|
|
4952
|
+
console.log(" \u2551 \u2551");
|
|
4953
|
+
console.log(" \u2551 By type: \u2551");
|
|
4954
|
+
for (const [type, count] of Object.entries(summary.byType)) {
|
|
4955
|
+
const label = type.padEnd(18);
|
|
4956
|
+
console.log(` \u2551 ${label} ${count.toString().padEnd(28)} \u2551`);
|
|
4957
|
+
}
|
|
4958
|
+
console.log(" \u2551 \u2551");
|
|
4959
|
+
}
|
|
4960
|
+
console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
|
|
4961
|
+
if (findings.length > 0) {
|
|
4962
|
+
console.log("");
|
|
4963
|
+
console.log(" Findings:");
|
|
4964
|
+
console.log("");
|
|
4965
|
+
const shown = findings.slice(0, 15);
|
|
4966
|
+
for (const f of shown) {
|
|
4967
|
+
const icon = f.severity === "critical" ? "\u{1F534}" : f.severity === "high" ? "\u{1F7E0}" : f.severity === "medium" ? "\u{1F7E1}" : "\u{1F535}";
|
|
4968
|
+
const sev = f.severity.toUpperCase().padEnd(8);
|
|
4969
|
+
console.log(` ${icon} ${sev} ${f.file}:${f.line}`);
|
|
4970
|
+
console.log(` ${f.type}: ${f.redacted}`);
|
|
4971
|
+
}
|
|
4972
|
+
if (findings.length > 15) {
|
|
4973
|
+
console.log(` ... and ${findings.length - 15} more (see .cto/audit/ for full report)`);
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
if (recommendations.length > 0) {
|
|
4977
|
+
console.log("");
|
|
4978
|
+
console.log(" Recommendations:");
|
|
4979
|
+
console.log("");
|
|
4980
|
+
for (const rec of recommendations) {
|
|
4981
|
+
const icon = rec.startsWith("CRITICAL") ? "\u{1F6A8}" : "\u{1F4A1}";
|
|
4982
|
+
console.log(` ${icon} ${rec}`);
|
|
4983
|
+
}
|
|
4984
|
+
}
|
|
4985
|
+
const ctoDir = join8(projectPath, ".cto");
|
|
4986
|
+
const auditDir = join8(ctoDir, "audit");
|
|
4987
|
+
mkdirSync3(auditDir, { recursive: true });
|
|
4988
|
+
const now = /* @__PURE__ */ new Date();
|
|
4989
|
+
const dateStr = now.toISOString().split("T")[0];
|
|
4990
|
+
const logFile = join8(auditDir, `${dateStr}.jsonl`);
|
|
4991
|
+
const logEntry = {
|
|
4992
|
+
timestamp: now.toISOString(),
|
|
4993
|
+
version: "3.2.0",
|
|
4994
|
+
summary: {
|
|
4995
|
+
filesScanned: summary.filesScanned,
|
|
4996
|
+
filesWithSecrets: summary.filesWithSecrets,
|
|
4997
|
+
totalFindings: summary.totalFindings,
|
|
4998
|
+
bySeverity: summary.bySeverity,
|
|
4999
|
+
byType: summary.byType
|
|
5000
|
+
},
|
|
5001
|
+
findings: findings.map((f) => ({
|
|
5002
|
+
type: f.type,
|
|
5003
|
+
file: f.file,
|
|
5004
|
+
line: f.line,
|
|
5005
|
+
severity: f.severity,
|
|
5006
|
+
redacted: f.redacted
|
|
5007
|
+
}))
|
|
5008
|
+
};
|
|
5009
|
+
appendFileSync2(logFile, JSON.stringify(logEntry) + "\n");
|
|
5010
|
+
let report = `# Security Audit Report
|
|
5011
|
+
|
|
5012
|
+
`;
|
|
5013
|
+
report += `> Generated by cto-ai-cli on ${now.toISOString()}
|
|
5014
|
+
|
|
5015
|
+
`;
|
|
5016
|
+
report += `## Summary
|
|
5017
|
+
|
|
5018
|
+
`;
|
|
5019
|
+
report += `| Metric | Value |
|
|
5020
|
+
`;
|
|
5021
|
+
report += `|--------|-------|
|
|
5022
|
+
`;
|
|
5023
|
+
report += `| Files scanned | ${summary.filesScanned} |
|
|
5024
|
+
`;
|
|
5025
|
+
report += `| Files with issues | ${summary.filesWithSecrets} |
|
|
5026
|
+
`;
|
|
5027
|
+
report += `| Total findings | ${summary.totalFindings} |
|
|
5028
|
+
`;
|
|
5029
|
+
report += `| Critical | ${summary.bySeverity.critical} |
|
|
5030
|
+
`;
|
|
5031
|
+
report += `| High | ${summary.bySeverity.high} |
|
|
5032
|
+
`;
|
|
5033
|
+
report += `| Medium | ${summary.bySeverity.medium} |
|
|
5034
|
+
|
|
5035
|
+
`;
|
|
5036
|
+
if (findings.length > 0) {
|
|
5037
|
+
report += `## Findings
|
|
5038
|
+
|
|
5039
|
+
`;
|
|
5040
|
+
report += `| Severity | Type | File | Line | Redacted |
|
|
5041
|
+
`;
|
|
5042
|
+
report += `|----------|------|------|------|----------|
|
|
5043
|
+
`;
|
|
5044
|
+
for (const f of findings) {
|
|
5045
|
+
report += `| ${f.severity} | ${f.type} | ${f.file} | ${f.line} | \`${f.redacted.slice(0, 30)}\` |
|
|
5046
|
+
`;
|
|
5047
|
+
}
|
|
5048
|
+
report += "\n";
|
|
5049
|
+
}
|
|
5050
|
+
if (recommendations.length > 0) {
|
|
5051
|
+
report += `## Recommendations
|
|
5052
|
+
|
|
5053
|
+
`;
|
|
5054
|
+
for (const rec of recommendations) {
|
|
5055
|
+
report += `- ${rec}
|
|
5056
|
+
`;
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
writeFileSync2(join8(auditDir, "report.md"), report);
|
|
5060
|
+
const envSecrets = findings.filter(
|
|
5061
|
+
(f) => f.type === "env-variable" || f.type === "password" || f.type === "api-key" || f.type === "aws-key" || f.type === "connection-string"
|
|
5062
|
+
);
|
|
5063
|
+
if (envSecrets.length > 0) {
|
|
5064
|
+
const envVarNames = /* @__PURE__ */ new Set();
|
|
5065
|
+
for (const f of envSecrets) {
|
|
5066
|
+
const varMatch = f.match.match(/^([A-Z_][A-Z0-9_]*)\s*[:=]/i);
|
|
5067
|
+
if (varMatch) {
|
|
5068
|
+
envVarNames.add(varMatch[1].toUpperCase());
|
|
5069
|
+
} else {
|
|
5070
|
+
const name = f.type.toUpperCase().replace(/-/g, "_");
|
|
5071
|
+
envVarNames.add(name);
|
|
5072
|
+
}
|
|
5073
|
+
}
|
|
5074
|
+
if (envVarNames.size > 0) {
|
|
5075
|
+
let envExample = "# Environment variables \u2014 NEVER commit real values\n";
|
|
5076
|
+
envExample += "# Generated by cto-ai-cli --audit\n\n";
|
|
5077
|
+
for (const name of envVarNames) {
|
|
5078
|
+
envExample += `${name}=your_${name.toLowerCase()}_here
|
|
5079
|
+
`;
|
|
5080
|
+
}
|
|
5081
|
+
writeFileSync2(join8(ctoDir, ".env.example"), envExample);
|
|
5082
|
+
}
|
|
5083
|
+
}
|
|
5084
|
+
console.log("");
|
|
5085
|
+
console.log(" \u{1F4C1} Audit artifacts:");
|
|
5086
|
+
console.log(` \u{1F4CB} .cto/audit/${dateStr}.jsonl Audit log (append-only)`);
|
|
5087
|
+
console.log(" \u{1F4CA} .cto/audit/report.md Full report");
|
|
5088
|
+
if (envSecrets.length > 0) {
|
|
5089
|
+
console.log(" \u{1F4DD} .cto/.env.example Template for environment variables");
|
|
5090
|
+
}
|
|
5091
|
+
console.log("");
|
|
5092
|
+
if (process.env.CI && (summary.bySeverity.critical > 0 || summary.bySeverity.high > 0)) {
|
|
5093
|
+
console.log(" \u274C CI mode: Failing due to critical/high severity findings.");
|
|
5094
|
+
process.exit(1);
|
|
5095
|
+
}
|
|
5096
|
+
}
|
|
2368
5097
|
function formatTokens(n) {
|
|
2369
5098
|
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
2370
5099
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
2371
5100
|
return n.toString();
|
|
2372
5101
|
}
|
|
5102
|
+
async function runMonorepo(projectPath, analysis, targetPackage, jsonMode) {
|
|
5103
|
+
const mono = await analyzeMonorepo(projectPath, analysis);
|
|
5104
|
+
if (jsonMode) {
|
|
5105
|
+
if (targetPackage) {
|
|
5106
|
+
const pkgCtx = selectPackageContext(mono, targetPackage);
|
|
5107
|
+
console.log(JSON.stringify({ monorepo: mono, packageContext: pkgCtx }, null, 2));
|
|
5108
|
+
} else {
|
|
5109
|
+
console.log(JSON.stringify(mono, null, 2));
|
|
5110
|
+
}
|
|
5111
|
+
return;
|
|
5112
|
+
}
|
|
5113
|
+
console.log(renderMonorepoAnalysis(mono));
|
|
5114
|
+
if (targetPackage && mono.detected) {
|
|
5115
|
+
const pkgCtx = selectPackageContext(mono, targetPackage);
|
|
5116
|
+
console.log(renderPackageContext(pkgCtx));
|
|
5117
|
+
}
|
|
5118
|
+
}
|
|
5119
|
+
async function runCIGate(projectPath, analysis, score, threshold, jsonMode) {
|
|
5120
|
+
const filePaths = analysis.files.map((f) => f.path);
|
|
5121
|
+
const auditResult = await auditProject(projectPath, filePaths, { includePII: false });
|
|
5122
|
+
const result = await runQualityGate(score, analysis, auditResult.findings, { threshold });
|
|
5123
|
+
await saveBaseline(projectPath, score);
|
|
5124
|
+
if (jsonMode) {
|
|
5125
|
+
console.log(JSON.stringify(result, null, 2));
|
|
5126
|
+
if (!result.passed) process.exit(1);
|
|
5127
|
+
return;
|
|
5128
|
+
}
|
|
5129
|
+
console.log("");
|
|
5130
|
+
console.log(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
5131
|
+
console.log(` \u{1F6A6} Quality Gate: ${result.passed ? "\u2705 PASSED" : "\u274C FAILED"}`);
|
|
5132
|
+
console.log(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
5133
|
+
console.log("");
|
|
5134
|
+
for (const check of result.checks) {
|
|
5135
|
+
const icon = check.passed ? "\u2705" : check.severity === "warning" ? "\u26A0\uFE0F" : "\u274C";
|
|
5136
|
+
console.log(` ${icon} ${check.name}: ${check.detail}`);
|
|
5137
|
+
}
|
|
5138
|
+
if (result.delta !== null) {
|
|
5139
|
+
const arrow = result.delta >= 0 ? "\u2191" : "\u2193";
|
|
5140
|
+
console.log("");
|
|
5141
|
+
console.log(` \u{1F4CA} Score: ${result.score}/100 (${result.grade}) ${arrow} ${Math.abs(result.delta)} from baseline`);
|
|
5142
|
+
}
|
|
5143
|
+
console.log("");
|
|
5144
|
+
console.log(" \u{1F4CB} Baseline saved to .cto/baseline.json");
|
|
5145
|
+
console.log("");
|
|
5146
|
+
if (!result.passed) {
|
|
5147
|
+
console.log(" \u274C Quality gate failed. Fix the issues above and re-run.");
|
|
5148
|
+
process.exit(1);
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
2373
5151
|
main();
|