tlc-claude-code 1.6.4 → 1.7.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/dashboard/dist/components/ContainerSecurityPane.d.ts +45 -0
- package/dashboard/dist/components/ContainerSecurityPane.js +44 -0
- package/dashboard/dist/components/ContainerSecurityPane.test.d.ts +1 -0
- package/dashboard/dist/components/ContainerSecurityPane.test.js +153 -0
- package/package.json +1 -1
- package/server/lib/access-control.test.js +1 -1
- package/server/lib/agents-cancel-command.test.js +1 -1
- package/server/lib/agents-get-command.test.js +1 -1
- package/server/lib/agents-list-command.test.js +1 -1
- package/server/lib/agents-logs-command.test.js +1 -1
- package/server/lib/agents-retry-command.test.js +1 -1
- package/server/lib/budget-limits.test.js +2 -2
- package/server/lib/code-gate/bypass-logger.js +129 -0
- package/server/lib/code-gate/bypass-logger.test.js +142 -0
- package/server/lib/code-gate/gate-command.js +114 -0
- package/server/lib/code-gate/gate-command.test.js +111 -0
- package/server/lib/code-gate/gate-config.js +163 -0
- package/server/lib/code-gate/gate-config.test.js +181 -0
- package/server/lib/code-gate/gate-engine.js +193 -0
- package/server/lib/code-gate/gate-engine.test.js +258 -0
- package/server/lib/code-gate/gate-reporter.js +123 -0
- package/server/lib/code-gate/gate-reporter.test.js +159 -0
- package/server/lib/code-gate/hooks-generator.js +149 -0
- package/server/lib/code-gate/hooks-generator.test.js +142 -0
- package/server/lib/code-gate/llm-reviewer.js +176 -0
- package/server/lib/code-gate/llm-reviewer.test.js +161 -0
- package/server/lib/code-gate/push-gate.js +133 -0
- package/server/lib/code-gate/push-gate.test.js +190 -0
- package/server/lib/code-gate/rules/architecture-rules.js +228 -0
- package/server/lib/code-gate/rules/architecture-rules.test.js +155 -0
- package/server/lib/code-gate/rules/client-rules.js +120 -0
- package/server/lib/code-gate/rules/client-rules.test.js +121 -0
- package/server/lib/code-gate/rules/config-rules.js +140 -0
- package/server/lib/code-gate/rules/config-rules.test.js +103 -0
- package/server/lib/code-gate/rules/database-rules.js +158 -0
- package/server/lib/code-gate/rules/database-rules.test.js +119 -0
- package/server/lib/code-gate/rules/docker-rules.js +201 -0
- package/server/lib/code-gate/rules/docker-rules.test.js +104 -0
- package/server/lib/code-gate/rules/quality-rules.js +304 -0
- package/server/lib/code-gate/rules/quality-rules.test.js +199 -0
- package/server/lib/code-gate/rules/security-rules.js +228 -0
- package/server/lib/code-gate/rules/security-rules.test.js +131 -0
- package/server/lib/code-gate/rules/structure-rules.js +155 -0
- package/server/lib/code-gate/rules/structure-rules.test.js +107 -0
- package/server/lib/code-gate/rules/test-rules.js +93 -0
- package/server/lib/code-gate/rules/test-rules.test.js +97 -0
- package/server/lib/code-gate/typescript-gate.js +128 -0
- package/server/lib/code-gate/typescript-gate.test.js +131 -0
- package/server/lib/code-generator.test.js +1 -1
- package/server/lib/cost-command.test.js +1 -1
- package/server/lib/cost-optimizer.test.js +1 -1
- package/server/lib/cost-projections.test.js +1 -1
- package/server/lib/cost-reports.test.js +1 -1
- package/server/lib/cost-tracker.test.js +1 -1
- package/server/lib/crypto-patterns.test.js +1 -1
- package/server/lib/design-command.test.js +1 -1
- package/server/lib/design-parser.test.js +1 -1
- package/server/lib/gemini-vision.test.js +1 -1
- package/server/lib/input-validator.test.js +1 -1
- package/server/lib/litellm-client.test.js +1 -1
- package/server/lib/litellm-command.test.js +1 -1
- package/server/lib/litellm-config.test.js +1 -1
- package/server/lib/model-pricing.test.js +1 -1
- package/server/lib/models-command.test.js +1 -1
- package/server/lib/optimize-command.test.js +1 -1
- package/server/lib/orchestration-integration.test.js +1 -1
- package/server/lib/output-encoder.test.js +1 -1
- package/server/lib/quality-evaluator.test.js +1 -1
- package/server/lib/quality-gate-command.test.js +1 -1
- package/server/lib/quality-gate-scorer.test.js +1 -1
- package/server/lib/quality-history.test.js +1 -1
- package/server/lib/quality-presets.test.js +1 -1
- package/server/lib/quality-retry.test.js +1 -1
- package/server/lib/quality-thresholds.test.js +1 -1
- package/server/lib/secure-auth.test.js +1 -1
- package/server/lib/secure-code-command.test.js +1 -1
- package/server/lib/secure-errors.test.js +1 -1
- package/server/lib/security/auth-security.test.js +4 -3
- package/server/lib/vision-command.test.js +1 -1
- package/server/lib/visual-command.test.js +1 -1
- package/server/lib/visual-testing.test.js +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface DockerfileFinding {
|
|
2
|
+
rule: string;
|
|
3
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
4
|
+
line?: number;
|
|
5
|
+
message: string;
|
|
6
|
+
fix: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RuntimeFinding {
|
|
9
|
+
rule: string;
|
|
10
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
11
|
+
service: string;
|
|
12
|
+
message: string;
|
|
13
|
+
fix: string;
|
|
14
|
+
}
|
|
15
|
+
export interface VulnerabilitySummary {
|
|
16
|
+
critical: number;
|
|
17
|
+
high: number;
|
|
18
|
+
medium: number;
|
|
19
|
+
low: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
export interface CisBenchmarkResult {
|
|
23
|
+
level1Score: number;
|
|
24
|
+
passed: boolean;
|
|
25
|
+
findings: Array<{
|
|
26
|
+
cis: string;
|
|
27
|
+
severity: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export interface ContainerSecurityPaneProps {
|
|
32
|
+
dockerfileLintScore: number;
|
|
33
|
+
dockerfileFindings: DockerfileFinding[];
|
|
34
|
+
runtimeScore: number;
|
|
35
|
+
runtimeFindings: RuntimeFinding[];
|
|
36
|
+
networkScore: number;
|
|
37
|
+
vulnerabilities: VulnerabilitySummary;
|
|
38
|
+
cisBenchmark: CisBenchmarkResult;
|
|
39
|
+
secretsScore: number;
|
|
40
|
+
onRescan?: () => void;
|
|
41
|
+
loading?: boolean;
|
|
42
|
+
error?: string | null;
|
|
43
|
+
isActive?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export declare function ContainerSecurityPane({ dockerfileLintScore, dockerfileFindings, runtimeScore, runtimeFindings, networkScore, vulnerabilities, cisBenchmark, secretsScore, onRescan, loading, error, isActive, }: ContainerSecurityPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
function getSeverityColor(severity) {
|
|
4
|
+
switch (severity) {
|
|
5
|
+
case 'critical':
|
|
6
|
+
return 'magenta';
|
|
7
|
+
case 'high':
|
|
8
|
+
return 'red';
|
|
9
|
+
case 'medium':
|
|
10
|
+
return 'yellow';
|
|
11
|
+
case 'low':
|
|
12
|
+
return 'cyan';
|
|
13
|
+
default:
|
|
14
|
+
return 'white';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function getScoreColor(score) {
|
|
18
|
+
if (score >= 80)
|
|
19
|
+
return 'green';
|
|
20
|
+
if (score >= 60)
|
|
21
|
+
return 'yellow';
|
|
22
|
+
return 'red';
|
|
23
|
+
}
|
|
24
|
+
function ScoreBar({ label, score }) {
|
|
25
|
+
const filled = Math.round(score / 5);
|
|
26
|
+
const empty = 20 - filled;
|
|
27
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { children: label }) }), _jsx(Text, { color: getScoreColor(score), children: bar }), _jsxs(Text, { children: [" ", score, "%"] })] }));
|
|
29
|
+
}
|
|
30
|
+
export function ContainerSecurityPane({ dockerfileLintScore, dockerfileFindings, runtimeScore, runtimeFindings, networkScore, vulnerabilities, cisBenchmark, secretsScore, onRescan, loading = false, error = null, isActive = false, }) {
|
|
31
|
+
useInput((input) => {
|
|
32
|
+
if (input === 'r' && onRescan) {
|
|
33
|
+
onRescan();
|
|
34
|
+
}
|
|
35
|
+
}, { isActive });
|
|
36
|
+
if (error) {
|
|
37
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Container Security Error" }), _jsx(Text, { color: "red", children: error })] }));
|
|
38
|
+
}
|
|
39
|
+
if (loading) {
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Container Security" }), _jsx(Text, { dimColor: true, children: "Scanning containers..." })] }));
|
|
41
|
+
}
|
|
42
|
+
const overallScore = Math.round((dockerfileLintScore + runtimeScore + networkScore + secretsScore) / 4);
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Container Security" }), _jsx(Text, { dimColor: true, children: "CIS Docker Benchmark compliance and security analysis" }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Overall Score: ", _jsxs(Text, { color: getScoreColor(overallScore), children: [overallScore, "%"] })] }), _jsx(Box, { marginTop: 1 }), _jsx(ScoreBar, { label: "Dockerfile Lint", score: dockerfileLintScore }), _jsx(ScoreBar, { label: "Runtime Security", score: runtimeScore }), _jsx(ScoreBar, { label: "Network Policy", score: networkScore }), _jsx(ScoreBar, { label: "Secrets Management", score: secretsScore })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "CIS Docker Benchmark Level 1" }), _jsxs(Text, { children: ["Score: ", _jsxs(Text, { color: cisBenchmark.passed ? 'green' : 'red', children: [cisBenchmark.level1Score, "%"] }), ' ', cisBenchmark.passed ? '\u2713 PASSED' : '\u2717 FAILED'] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Image Vulnerabilities" }), _jsxs(Box, { children: [_jsxs(Text, { color: "magenta", children: ["Critical: ", vulnerabilities.critical] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "red", children: ["High: ", vulnerabilities.high] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "yellow", children: ["Medium: ", vulnerabilities.medium] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "cyan", children: ["Low: ", vulnerabilities.low] })] })] }), _jsx(Box, { marginTop: 1 }), dockerfileFindings.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Dockerfile Findings (", dockerfileFindings.length, ")"] }), dockerfileFindings.slice(0, 5).map((f, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: getSeverityColor(f.severity), children: ["[", f.severity.toUpperCase(), "]"] }), _jsxs(Text, { children: [" ", f.message] })] }, `df-${i}`))), dockerfileFindings.length > 5 && (_jsxs(Text, { dimColor: true, children: ["...and ", dockerfileFindings.length - 5, " more"] }))] })), runtimeFindings.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Runtime Findings (", runtimeFindings.length, ")"] }), runtimeFindings.slice(0, 5).map((f, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: getSeverityColor(f.severity), children: ["[", f.severity.toUpperCase(), "]"] }), _jsxs(Text, { children: [" ", f.service, ": ", f.message] })] }, `rt-${i}`))), runtimeFindings.length > 5 && (_jsxs(Text, { dimColor: true, children: ["...and ", runtimeFindings.length - 5, " more"] }))] })), isActive && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press [r] to rescan" }) }))] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { ContainerSecurityPane } from './ContainerSecurityPane.js';
|
|
5
|
+
describe('ContainerSecurityPane', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
const mockDockerfileFindings = [
|
|
13
|
+
{
|
|
14
|
+
rule: 'no-root-user',
|
|
15
|
+
severity: 'high',
|
|
16
|
+
line: 1,
|
|
17
|
+
message: 'No USER directive found',
|
|
18
|
+
fix: 'Add USER directive',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
rule: 'latest-tag',
|
|
22
|
+
severity: 'medium',
|
|
23
|
+
message: 'Using latest tag',
|
|
24
|
+
fix: 'Pin to specific version',
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
const mockRuntimeFindings = [
|
|
28
|
+
{
|
|
29
|
+
rule: 'privileged',
|
|
30
|
+
severity: 'critical',
|
|
31
|
+
service: 'web',
|
|
32
|
+
message: 'Running in privileged mode',
|
|
33
|
+
fix: 'Remove privileged: true',
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
const mockVulnerabilities = {
|
|
37
|
+
critical: 0,
|
|
38
|
+
high: 2,
|
|
39
|
+
medium: 5,
|
|
40
|
+
low: 10,
|
|
41
|
+
total: 17,
|
|
42
|
+
};
|
|
43
|
+
const mockCisBenchmark = {
|
|
44
|
+
level1Score: 85,
|
|
45
|
+
passed: true,
|
|
46
|
+
findings: [
|
|
47
|
+
{ cis: '4.1', severity: 'high', message: 'No non-root USER directive found.' },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const defaultProps = {
|
|
51
|
+
dockerfileLintScore: 80,
|
|
52
|
+
dockerfileFindings: mockDockerfileFindings,
|
|
53
|
+
runtimeScore: 70,
|
|
54
|
+
runtimeFindings: mockRuntimeFindings,
|
|
55
|
+
networkScore: 90,
|
|
56
|
+
vulnerabilities: mockVulnerabilities,
|
|
57
|
+
cisBenchmark: mockCisBenchmark,
|
|
58
|
+
secretsScore: 85,
|
|
59
|
+
};
|
|
60
|
+
it('renders container security header', () => {
|
|
61
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
62
|
+
expect(lastFrame()).toContain('Container Security');
|
|
63
|
+
});
|
|
64
|
+
it('shows overall score', () => {
|
|
65
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
66
|
+
// (80 + 70 + 90 + 85) / 4 = 81.25 => 81
|
|
67
|
+
expect(lastFrame()).toContain('81%');
|
|
68
|
+
});
|
|
69
|
+
it('shows score bars for each category', () => {
|
|
70
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
71
|
+
expect(lastFrame()).toContain('Dockerfile Lint');
|
|
72
|
+
expect(lastFrame()).toContain('Runtime Security');
|
|
73
|
+
expect(lastFrame()).toContain('Network Policy');
|
|
74
|
+
expect(lastFrame()).toContain('Secrets Management');
|
|
75
|
+
});
|
|
76
|
+
it('shows CIS benchmark result', () => {
|
|
77
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
78
|
+
expect(lastFrame()).toContain('CIS Docker Benchmark Level 1');
|
|
79
|
+
expect(lastFrame()).toContain('85%');
|
|
80
|
+
expect(lastFrame()).toContain('PASSED');
|
|
81
|
+
});
|
|
82
|
+
it('shows CIS benchmark failure', () => {
|
|
83
|
+
const failedBenchmark = {
|
|
84
|
+
level1Score: 45,
|
|
85
|
+
passed: false,
|
|
86
|
+
findings: [],
|
|
87
|
+
};
|
|
88
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, cisBenchmark: failedBenchmark }));
|
|
89
|
+
expect(lastFrame()).toContain('45%');
|
|
90
|
+
expect(lastFrame()).toContain('FAILED');
|
|
91
|
+
});
|
|
92
|
+
it('shows vulnerability summary', () => {
|
|
93
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
94
|
+
expect(lastFrame()).toContain('Image Vulnerabilities');
|
|
95
|
+
expect(lastFrame()).toContain('Critical: 0');
|
|
96
|
+
expect(lastFrame()).toContain('High: 2');
|
|
97
|
+
expect(lastFrame()).toContain('Medium: 5');
|
|
98
|
+
expect(lastFrame()).toContain('Low: 10');
|
|
99
|
+
});
|
|
100
|
+
it('shows dockerfile findings', () => {
|
|
101
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
102
|
+
expect(lastFrame()).toContain('Dockerfile Findings (2)');
|
|
103
|
+
expect(lastFrame()).toContain('No USER directive found');
|
|
104
|
+
expect(lastFrame()).toContain('[HIGH]');
|
|
105
|
+
});
|
|
106
|
+
it('shows runtime findings', () => {
|
|
107
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps }));
|
|
108
|
+
expect(lastFrame()).toContain('Runtime Findings (1)');
|
|
109
|
+
expect(lastFrame()).toContain('Running in privileged mode');
|
|
110
|
+
expect(lastFrame()).toContain('[CRITICAL]');
|
|
111
|
+
});
|
|
112
|
+
it('shows loading state', () => {
|
|
113
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, loading: true }));
|
|
114
|
+
expect(lastFrame()).toContain('Scanning containers');
|
|
115
|
+
});
|
|
116
|
+
it('shows error state', () => {
|
|
117
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, error: "Docker not running" }));
|
|
118
|
+
expect(lastFrame()).toContain('Docker not running');
|
|
119
|
+
});
|
|
120
|
+
it('shows rescan hint when active', () => {
|
|
121
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, isActive: true }));
|
|
122
|
+
expect(lastFrame()).toContain('Press [r] to rescan');
|
|
123
|
+
});
|
|
124
|
+
it('hides rescan hint when not active', () => {
|
|
125
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, isActive: false }));
|
|
126
|
+
expect(lastFrame()).not.toContain('Press [r] to rescan');
|
|
127
|
+
});
|
|
128
|
+
it('truncates findings list beyond 5', () => {
|
|
129
|
+
const manyFindings = Array.from({ length: 8 }, (_, i) => ({
|
|
130
|
+
rule: `rule-${i}`,
|
|
131
|
+
severity: 'medium',
|
|
132
|
+
message: `Finding ${i}`,
|
|
133
|
+
fix: `Fix ${i}`,
|
|
134
|
+
}));
|
|
135
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, dockerfileFindings: manyFindings }));
|
|
136
|
+
expect(lastFrame()).toContain('Dockerfile Findings (8)');
|
|
137
|
+
expect(lastFrame()).toContain('...and 3 more');
|
|
138
|
+
});
|
|
139
|
+
it('handles zero findings gracefully', () => {
|
|
140
|
+
const { lastFrame } = render(_jsx(ContainerSecurityPane, { ...defaultProps, dockerfileFindings: [], runtimeFindings: [] }));
|
|
141
|
+
expect(lastFrame()).toContain('Container Security');
|
|
142
|
+
expect(lastFrame()).not.toContain('Dockerfile Findings');
|
|
143
|
+
expect(lastFrame()).not.toContain('Runtime Findings');
|
|
144
|
+
});
|
|
145
|
+
it('calls onRescan when r is pressed', async () => {
|
|
146
|
+
const onRescan = vi.fn();
|
|
147
|
+
const { stdin } = render(_jsx(ContainerSecurityPane, { ...defaultProps, isActive: true, onRescan: onRescan }));
|
|
148
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
149
|
+
stdin.write('r');
|
|
150
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
151
|
+
expect(onRescan).toHaveBeenCalledOnce();
|
|
152
|
+
});
|
|
153
|
+
});
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Configurable budget limits with enforcement
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { describe, it, beforeEach } from 'vitest';
|
|
8
8
|
const assert = require('node:assert');
|
|
9
9
|
|
|
10
10
|
const {
|
|
@@ -53,7 +53,7 @@ describe('Budget Limits', () => {
|
|
|
53
53
|
it('returns ok under limit', () => {
|
|
54
54
|
setBudget(manager, { type: 'daily', limit: 10.00 });
|
|
55
55
|
|
|
56
|
-
const result = checkBudget(manager, { currentSpend:
|
|
56
|
+
const result = checkBudget(manager, { currentSpend: 3.00 });
|
|
57
57
|
|
|
58
58
|
assert.strictEqual(result.status, 'ok');
|
|
59
59
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bypass Logger
|
|
3
|
+
*
|
|
4
|
+
* Logs when someone uses --no-verify to bypass the code gate.
|
|
5
|
+
* Maintains an append-only JSONL audit trail.
|
|
6
|
+
*
|
|
7
|
+
* @module code-gate/bypass-logger
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
/** Default audit file path relative to project root */
|
|
14
|
+
const AUDIT_FILE = '.tlc/audit/gate-bypasses.jsonl';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Log a gate bypass event to the audit trail.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} event
|
|
20
|
+
* @param {string} event.user - Who bypassed
|
|
21
|
+
* @param {string} event.commitHash - Commit hash
|
|
22
|
+
* @param {string[]} event.filesChanged - Files in the commit
|
|
23
|
+
* @param {string} [event.hookType] - Which hook was bypassed
|
|
24
|
+
* @param {Object} [options]
|
|
25
|
+
* @param {Object} [options.fs] - File system module
|
|
26
|
+
* @param {string} [options.projectPath] - Project root
|
|
27
|
+
*/
|
|
28
|
+
function logBypass(event, options = {}) {
|
|
29
|
+
const fsModule = options.fs || fs;
|
|
30
|
+
const projectPath = options.projectPath || process.cwd();
|
|
31
|
+
const auditPath = path.join(projectPath, AUDIT_FILE);
|
|
32
|
+
const auditDir = path.dirname(auditPath);
|
|
33
|
+
|
|
34
|
+
if (!fsModule.existsSync(auditDir)) {
|
|
35
|
+
fsModule.mkdirSync(auditDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const record = {
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
user: event.user,
|
|
41
|
+
commitHash: event.commitHash,
|
|
42
|
+
filesChanged: event.filesChanged,
|
|
43
|
+
hookType: event.hookType || 'unknown',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
fsModule.appendFileSync(auditPath, JSON.stringify(record) + '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the bypass audit history.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} projectPath
|
|
53
|
+
* @param {Object} [options]
|
|
54
|
+
* @param {Object} [options.fs] - File system module
|
|
55
|
+
* @returns {Array<Object>} Array of bypass events
|
|
56
|
+
*/
|
|
57
|
+
function readBypassHistory(projectPath, options = {}) {
|
|
58
|
+
const fsModule = options.fs || fs;
|
|
59
|
+
const auditPath = path.join(projectPath, AUDIT_FILE);
|
|
60
|
+
|
|
61
|
+
if (!fsModule.existsSync(auditPath)) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const content = fsModule.readFileSync(auditPath, 'utf-8');
|
|
66
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
67
|
+
const entries = [];
|
|
68
|
+
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
try {
|
|
71
|
+
entries.push(JSON.parse(line));
|
|
72
|
+
} catch {
|
|
73
|
+
// Skip malformed lines
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Calculate bypass count per user.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<{user: string}>} history
|
|
84
|
+
* @returns {Object.<string, number>} User to bypass count map
|
|
85
|
+
*/
|
|
86
|
+
function getBypassRate(history) {
|
|
87
|
+
const rates = {};
|
|
88
|
+
for (const entry of history) {
|
|
89
|
+
rates[entry.user] = (rates[entry.user] || 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
return rates;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format bypass history as a readable report.
|
|
96
|
+
*
|
|
97
|
+
* @param {Array} history
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function formatBypassReport(history) {
|
|
101
|
+
if (history.length === 0) {
|
|
102
|
+
return 'No bypasses recorded. Gate compliance is 100%.';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let report = 'Gate Bypass Report\n';
|
|
106
|
+
report += '─'.repeat(42) + '\n\n';
|
|
107
|
+
|
|
108
|
+
const rates = getBypassRate(history);
|
|
109
|
+
report += 'Bypasses by user:\n';
|
|
110
|
+
for (const [user, count] of Object.entries(rates)) {
|
|
111
|
+
report += ` ${user}: ${count} bypass${count > 1 ? 'es' : ''}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
report += `\nRecent bypasses:\n`;
|
|
115
|
+
const recent = history.slice(-5);
|
|
116
|
+
for (const entry of recent) {
|
|
117
|
+
const date = entry.timestamp ? entry.timestamp.split('T')[0] : 'unknown';
|
|
118
|
+
report += ` ${date} ${entry.user} (${entry.commitHash})\n`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return report;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
logBypass,
|
|
126
|
+
readBypassHistory,
|
|
127
|
+
getBypassRate,
|
|
128
|
+
formatBypassReport,
|
|
129
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bypass Logger Tests
|
|
3
|
+
*
|
|
4
|
+
* Logs when someone uses --no-verify to bypass the gate.
|
|
5
|
+
* Tracks bypass frequency per user.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
logBypass,
|
|
11
|
+
readBypassHistory,
|
|
12
|
+
getBypassRate,
|
|
13
|
+
formatBypassReport,
|
|
14
|
+
} = require('./bypass-logger.js');
|
|
15
|
+
|
|
16
|
+
describe('Bypass Logger', () => {
|
|
17
|
+
describe('logBypass', () => {
|
|
18
|
+
it('appends bypass event to audit file', () => {
|
|
19
|
+
let written = '';
|
|
20
|
+
const mockFs = {
|
|
21
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
22
|
+
mkdirSync: vi.fn(),
|
|
23
|
+
appendFileSync: vi.fn((path, data) => { written = data; }),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
logBypass({
|
|
27
|
+
user: 'alice',
|
|
28
|
+
commitHash: 'abc123',
|
|
29
|
+
filesChanged: ['src/app.js'],
|
|
30
|
+
hookType: 'pre-commit',
|
|
31
|
+
}, { fs: mockFs });
|
|
32
|
+
|
|
33
|
+
const parsed = JSON.parse(written.trim());
|
|
34
|
+
expect(parsed.user).toBe('alice');
|
|
35
|
+
expect(parsed.commitHash).toBe('abc123');
|
|
36
|
+
expect(parsed.filesChanged).toEqual(['src/app.js']);
|
|
37
|
+
expect(parsed.timestamp).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('creates audit directory if missing', () => {
|
|
41
|
+
const mockFs = {
|
|
42
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
43
|
+
mkdirSync: vi.fn(),
|
|
44
|
+
appendFileSync: vi.fn(),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
logBypass({ user: 'bob', commitHash: 'def456', filesChanged: [] }, { fs: mockFs });
|
|
48
|
+
expect(mockFs.mkdirSync).toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('writes to .tlc/audit/gate-bypasses.jsonl', () => {
|
|
52
|
+
let writtenPath = '';
|
|
53
|
+
const mockFs = {
|
|
54
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
55
|
+
mkdirSync: vi.fn(),
|
|
56
|
+
appendFileSync: vi.fn((path) => { writtenPath = path; }),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
logBypass({ user: 'x', commitHash: 'y', filesChanged: [] }, { fs: mockFs });
|
|
60
|
+
expect(writtenPath).toContain('gate-bypasses.jsonl');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('readBypassHistory', () => {
|
|
65
|
+
it('reads and parses JSONL file', () => {
|
|
66
|
+
const lines = [
|
|
67
|
+
JSON.stringify({ user: 'alice', timestamp: '2024-01-01T00:00:00Z', commitHash: 'a1' }),
|
|
68
|
+
JSON.stringify({ user: 'bob', timestamp: '2024-01-02T00:00:00Z', commitHash: 'b2' }),
|
|
69
|
+
].join('\n');
|
|
70
|
+
|
|
71
|
+
const mockFs = {
|
|
72
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
73
|
+
readFileSync: vi.fn().mockReturnValue(lines),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const history = readBypassHistory('/project', { fs: mockFs });
|
|
77
|
+
expect(history).toHaveLength(2);
|
|
78
|
+
expect(history[0].user).toBe('alice');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns empty array when no file exists', () => {
|
|
82
|
+
const mockFs = {
|
|
83
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const history = readBypassHistory('/project', { fs: mockFs });
|
|
87
|
+
expect(history).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('skips malformed lines', () => {
|
|
91
|
+
const lines = [
|
|
92
|
+
JSON.stringify({ user: 'alice', commitHash: 'a1' }),
|
|
93
|
+
'not valid json',
|
|
94
|
+
JSON.stringify({ user: 'bob', commitHash: 'b2' }),
|
|
95
|
+
].join('\n');
|
|
96
|
+
|
|
97
|
+
const mockFs = {
|
|
98
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
99
|
+
readFileSync: vi.fn().mockReturnValue(lines),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const history = readBypassHistory('/project', { fs: mockFs });
|
|
103
|
+
expect(history).toHaveLength(2);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('getBypassRate', () => {
|
|
108
|
+
it('calculates bypasses per user', () => {
|
|
109
|
+
const history = [
|
|
110
|
+
{ user: 'alice', timestamp: '2024-01-01' },
|
|
111
|
+
{ user: 'alice', timestamp: '2024-01-02' },
|
|
112
|
+
{ user: 'bob', timestamp: '2024-01-03' },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const rates = getBypassRate(history);
|
|
116
|
+
expect(rates.alice).toBe(2);
|
|
117
|
+
expect(rates.bob).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns empty object for empty history', () => {
|
|
121
|
+
const rates = getBypassRate([]);
|
|
122
|
+
expect(Object.keys(rates)).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('formatBypassReport', () => {
|
|
127
|
+
it('formats bypass history as readable report', () => {
|
|
128
|
+
const history = [
|
|
129
|
+
{ user: 'alice', timestamp: '2024-01-01T00:00:00Z', commitHash: 'abc123', hookType: 'pre-commit' },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const report = formatBypassReport(history);
|
|
133
|
+
expect(report).toContain('alice');
|
|
134
|
+
expect(report).toContain('abc123');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shows all-clear when no bypasses', () => {
|
|
138
|
+
const report = formatBypassReport([]);
|
|
139
|
+
expect(report).toContain('No bypasses');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|