lean-claudient-daemon 0.1.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/README.md +39 -0
- package/dist/api.js +54 -0
- package/dist/bin/daemon.js +14 -0
- package/dist/metrics.js +31 -0
- package/dist/metricsStore.js +84 -0
- package/dist/middleware/rateLimiting.js +193 -0
- package/dist/middleware/securityHeaders.js +239 -0
- package/dist/middleware/validation.js +181 -0
- package/dist/security/envValidation.js +266 -0
- package/dist/service.js +47 -0
- package/dist/validation/schemas.js +157 -0
- package/package.json +49 -0
- package/src/api.ts +58 -0
- package/src/bin/daemon.ts +18 -0
- package/src/metrics.ts +45 -0
- package/src/metricsStore.ts +114 -0
- package/src/middleware/rateLimiting.ts +244 -0
- package/src/middleware/securityHeaders.ts +305 -0
- package/src/middleware/validation.ts +222 -0
- package/src/security/envValidation.ts +321 -0
- package/src/service.ts +62 -0
- package/src/validation/schemas.ts +190 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Validation Middleware
|
|
3
|
+
* Express middleware for validating request bodies and parameters
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
/**
|
|
7
|
+
* Create validation middleware for a given schema
|
|
8
|
+
*/
|
|
9
|
+
export function createValidationMiddleware(schema) {
|
|
10
|
+
return (req, res, next) => {
|
|
11
|
+
try {
|
|
12
|
+
const validated = schema.parse(req.body);
|
|
13
|
+
req.validatedBody = validated;
|
|
14
|
+
next();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error instanceof z.ZodError) {
|
|
18
|
+
const errors = error.errors.map((e) => ({
|
|
19
|
+
field: e.path.join('.'),
|
|
20
|
+
message: e.message,
|
|
21
|
+
}));
|
|
22
|
+
return res.status(400).json({
|
|
23
|
+
error: 'Validation failed',
|
|
24
|
+
code: 'VALIDATION_ERROR',
|
|
25
|
+
details: errors,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return res.status(400).json({
|
|
29
|
+
error: 'Invalid request',
|
|
30
|
+
code: 'INVALID_REQUEST',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate request body
|
|
37
|
+
*/
|
|
38
|
+
export function validateRequestBody(data, schema) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = schema.parse(data);
|
|
41
|
+
return { valid: true, data: parsed };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof z.ZodError) {
|
|
45
|
+
const errors = error.errors.map((e) => ({
|
|
46
|
+
field: e.path.join('.'),
|
|
47
|
+
message: e.message,
|
|
48
|
+
}));
|
|
49
|
+
return { valid: false, errors };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
valid: false,
|
|
53
|
+
errors: [{ field: 'unknown', message: 'Validation failed' }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Validate UUID in path parameter
|
|
59
|
+
*/
|
|
60
|
+
export function validatePathUUID(req, res, next, id, paramName = 'id') {
|
|
61
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
62
|
+
if (!uuidPattern.test(id)) {
|
|
63
|
+
return res.status(400).json({
|
|
64
|
+
error: 'Invalid UUID format',
|
|
65
|
+
code: 'INVALID_UUID',
|
|
66
|
+
details: {
|
|
67
|
+
field: paramName,
|
|
68
|
+
message: 'Must be a valid UUID (36 characters with hyphens)',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
next();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Middleware to validate UUID parameter 'id'
|
|
76
|
+
*/
|
|
77
|
+
export function validateIdParam(req, res, next) {
|
|
78
|
+
const { id } = req.params;
|
|
79
|
+
if (!id) {
|
|
80
|
+
return res.status(400).json({
|
|
81
|
+
error: 'Missing id parameter',
|
|
82
|
+
code: 'MISSING_PARAM',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
validatePathUUID(req, res, next, id, 'id');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Middleware to validate UUID parameter 'sessionId'
|
|
89
|
+
*/
|
|
90
|
+
export function validateSessionIdParam(req, res, next) {
|
|
91
|
+
const { sessionId } = req.params;
|
|
92
|
+
if (!sessionId) {
|
|
93
|
+
return res.status(400).json({
|
|
94
|
+
error: 'Missing sessionId parameter',
|
|
95
|
+
code: 'MISSING_PARAM',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
validatePathUUID(req, res, next, sessionId, 'sessionId');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Middleware to ensure Content-Type is application/json
|
|
102
|
+
*/
|
|
103
|
+
export function validateContentType(req, res, next) {
|
|
104
|
+
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
|
|
105
|
+
const contentType = req.get('Content-Type');
|
|
106
|
+
if (!contentType || !contentType.includes('application/json')) {
|
|
107
|
+
return res.status(415).json({
|
|
108
|
+
error: 'Unsupported Media Type',
|
|
109
|
+
code: 'UNSUPPORTED_MEDIA_TYPE',
|
|
110
|
+
details: {
|
|
111
|
+
message: 'Content-Type must be application/json',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
next();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Middleware to limit request body size
|
|
120
|
+
*/
|
|
121
|
+
export function validateRequestSize(maxSize = '1mb') {
|
|
122
|
+
return (req, res, next) => {
|
|
123
|
+
if (!req.get('Content-Length')) {
|
|
124
|
+
return next();
|
|
125
|
+
}
|
|
126
|
+
const sizeBytes = parseSize(maxSize);
|
|
127
|
+
const contentLength = parseInt(req.get('Content-Length') || '0', 10);
|
|
128
|
+
if (contentLength > sizeBytes) {
|
|
129
|
+
return res.status(413).json({
|
|
130
|
+
error: 'Payload Too Large',
|
|
131
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
132
|
+
details: {
|
|
133
|
+
maxSize,
|
|
134
|
+
received: `${(contentLength / 1024).toFixed(2)}kb`,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
next();
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse size string to bytes
|
|
143
|
+
*/
|
|
144
|
+
function parseSize(size) {
|
|
145
|
+
const units = {
|
|
146
|
+
b: 1,
|
|
147
|
+
kb: 1024,
|
|
148
|
+
mb: 1024 * 1024,
|
|
149
|
+
gb: 1024 * 1024 * 1024,
|
|
150
|
+
};
|
|
151
|
+
const match = size.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(kb|mb|gb|b)?$/);
|
|
152
|
+
if (!match)
|
|
153
|
+
return 1024 * 1024; // default 1mb
|
|
154
|
+
const [, number, unit = 'b'] = match;
|
|
155
|
+
return Math.floor(parseFloat(number) * (units[unit] || 1));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validate query parameters
|
|
159
|
+
*/
|
|
160
|
+
export function validateQueryParams(data, allowedKeys) {
|
|
161
|
+
if (typeof data !== 'object' || data === null) {
|
|
162
|
+
return { valid: false, errors: [{ field: 'query', message: 'Invalid query parameters' }] };
|
|
163
|
+
}
|
|
164
|
+
const errors = [];
|
|
165
|
+
const validated = {};
|
|
166
|
+
for (const [key, value] of Object.entries(data)) {
|
|
167
|
+
if (!allowedKeys.includes(key)) {
|
|
168
|
+
errors.push({
|
|
169
|
+
field: key,
|
|
170
|
+
message: `Unknown parameter: ${key}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
validated[key] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (errors.length > 0) {
|
|
178
|
+
return { valid: false, errors };
|
|
179
|
+
}
|
|
180
|
+
return { valid: true, data: validated };
|
|
181
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment Validation
|
|
3
|
+
* Validates daemon security configuration at startup
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, accessSync, constants } from 'fs';
|
|
6
|
+
import { dirname } from 'path';
|
|
7
|
+
// Mock detectSecrets function since core/security is not accessible from daemon
|
|
8
|
+
function detectSecrets(text) {
|
|
9
|
+
// Placeholder for secrets detection
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Check if process environment contains suspicious secrets
|
|
14
|
+
*/
|
|
15
|
+
export function checkEnvironmentSecrets() {
|
|
16
|
+
const envString = JSON.stringify(process.env);
|
|
17
|
+
const findings = detectSecrets(envString);
|
|
18
|
+
if (findings.length === 0) {
|
|
19
|
+
return { status: 'PASS', message: 'No hardcoded secrets in environment' };
|
|
20
|
+
}
|
|
21
|
+
const critical = findings.filter((f) => f.severity === 'CRITICAL');
|
|
22
|
+
const high = findings.filter((f) => f.severity === 'HIGH');
|
|
23
|
+
if (critical.length > 0) {
|
|
24
|
+
return {
|
|
25
|
+
status: 'WARN',
|
|
26
|
+
message: `Found ${critical.length} CRITICAL and ${high.length} HIGH severity secrets in environment`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
status: 'PASS',
|
|
31
|
+
message: `Found ${high.length} potential high-entropy strings (may be expected secrets)`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate daemon port is not privileged
|
|
36
|
+
*/
|
|
37
|
+
export function checkDaemonPort(port) {
|
|
38
|
+
if (port < 1024) {
|
|
39
|
+
return {
|
|
40
|
+
status: 'FAIL',
|
|
41
|
+
message: `Port ${port} is privileged (<1024). Use unprivileged port or run as root (not recommended).`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (port > 65535) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'FAIL',
|
|
47
|
+
message: `Port ${port} is invalid (>65535)`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status: 'PASS',
|
|
52
|
+
message: `Daemon port ${port} is valid`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Check if log directory is writable
|
|
57
|
+
*/
|
|
58
|
+
export function checkLogDirectory(logDir) {
|
|
59
|
+
// Create directory if it doesn't exist
|
|
60
|
+
if (!existsSync(logDir)) {
|
|
61
|
+
try {
|
|
62
|
+
const parentDir = dirname(logDir);
|
|
63
|
+
if (!existsSync(parentDir)) {
|
|
64
|
+
return {
|
|
65
|
+
status: 'WARN',
|
|
66
|
+
message: `Log directory parent doesn't exist: ${parentDir}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
accessSync(parentDir, constants.W_OK);
|
|
70
|
+
return {
|
|
71
|
+
status: 'PASS',
|
|
72
|
+
message: `Log directory can be created: ${logDir}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {
|
|
77
|
+
status: 'WARN',
|
|
78
|
+
message: `Cannot write to log directory parent: ${dirname(logDir)}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
accessSync(logDir, constants.W_OK);
|
|
84
|
+
return {
|
|
85
|
+
status: 'PASS',
|
|
86
|
+
message: `Log directory is writable: ${logDir}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return {
|
|
91
|
+
status: 'WARN',
|
|
92
|
+
message: `Log directory is not writable: ${logDir}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if database path is writable
|
|
98
|
+
*/
|
|
99
|
+
export function checkDatabasePath(dbPath) {
|
|
100
|
+
const dbDir = dirname(dbPath);
|
|
101
|
+
if (!existsSync(dbDir)) {
|
|
102
|
+
const parentDir = dirname(dbDir);
|
|
103
|
+
try {
|
|
104
|
+
accessSync(parentDir, constants.W_OK);
|
|
105
|
+
return {
|
|
106
|
+
status: 'PASS',
|
|
107
|
+
message: `Database directory can be created: ${dbDir}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {
|
|
112
|
+
status: 'WARN',
|
|
113
|
+
message: `Cannot write to database directory parent: ${parentDir}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
accessSync(dbDir, constants.W_OK);
|
|
119
|
+
return {
|
|
120
|
+
status: 'PASS',
|
|
121
|
+
message: `Database directory is writable: ${dbDir}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return {
|
|
126
|
+
status: 'WARN',
|
|
127
|
+
message: `Database directory is not writable: ${dbDir}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Check required environment variables
|
|
133
|
+
*/
|
|
134
|
+
export function checkRequiredEnvVars(required) {
|
|
135
|
+
const missing = required.filter((key) => !process.env[key]);
|
|
136
|
+
if (missing.length === 0) {
|
|
137
|
+
return {
|
|
138
|
+
status: 'PASS',
|
|
139
|
+
message: `All required environment variables are set`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
status: 'FAIL',
|
|
144
|
+
message: `Missing environment variables: ${missing.join(', ')}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Check for deprecated or insecure environment variables
|
|
149
|
+
*/
|
|
150
|
+
export function checkDeprecatedEnvVars() {
|
|
151
|
+
const deprecated = [
|
|
152
|
+
'DEBUG',
|
|
153
|
+
'VERBOSE',
|
|
154
|
+
'LOG_LEVEL', // prefer structured logging
|
|
155
|
+
];
|
|
156
|
+
const found = deprecated.filter((key) => process.env[key]);
|
|
157
|
+
if (found.length === 0) {
|
|
158
|
+
return {
|
|
159
|
+
status: 'PASS',
|
|
160
|
+
message: 'No deprecated environment variables detected',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: 'WARN',
|
|
165
|
+
message: `Found deprecated environment variables: ${found.join(', ')}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Validate NODE_ENV is set appropriately
|
|
170
|
+
*/
|
|
171
|
+
export function checkNodeEnv() {
|
|
172
|
+
const nodeEnv = process.env.NODE_ENV || 'development';
|
|
173
|
+
if (nodeEnv === 'production' || nodeEnv === 'staging' || nodeEnv === 'development') {
|
|
174
|
+
return {
|
|
175
|
+
status: 'PASS',
|
|
176
|
+
message: `NODE_ENV is set to: ${nodeEnv}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
status: 'WARN',
|
|
181
|
+
message: `NODE_ENV has unexpected value: ${nodeEnv}. Should be production, staging, or development.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Check memory and resource limits
|
|
186
|
+
*/
|
|
187
|
+
export function checkResourceLimits() {
|
|
188
|
+
const memUsage = process.memoryUsage();
|
|
189
|
+
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
|
190
|
+
const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
|
|
191
|
+
if (heapTotalMB > 4096) {
|
|
192
|
+
return {
|
|
193
|
+
status: 'WARN',
|
|
194
|
+
message: `Daemon has high heap allocation: ${heapTotalMB}MB (${heapUsedMB}MB used)`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
status: 'PASS',
|
|
199
|
+
message: `Memory usage: ${heapUsedMB}MB / ${heapTotalMB}MB`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Full validation suite
|
|
204
|
+
*/
|
|
205
|
+
export function validateEnvironment(config) {
|
|
206
|
+
const checks = [
|
|
207
|
+
{ name: 'Environment Secrets', ...checkEnvironmentSecrets() },
|
|
208
|
+
{ name: 'Daemon Port', ...checkDaemonPort(config.port) },
|
|
209
|
+
{ name: 'Log Directory', ...checkLogDirectory(config.logDir) },
|
|
210
|
+
{ name: 'Database Path', ...checkDatabasePath(config.dbPath) },
|
|
211
|
+
{ name: 'NODE_ENV', ...checkNodeEnv() },
|
|
212
|
+
{ name: 'Deprecated Variables', ...checkDeprecatedEnvVars() },
|
|
213
|
+
{ name: 'Resource Limits', ...checkResourceLimits() },
|
|
214
|
+
];
|
|
215
|
+
if (config.requiredEnvVars && config.requiredEnvVars.length > 0) {
|
|
216
|
+
checks.push({
|
|
217
|
+
name: 'Required Variables',
|
|
218
|
+
...checkRequiredEnvVars(config.requiredEnvVars),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const failures = checks.filter((c) => c.status === 'FAIL');
|
|
222
|
+
const warnings = checks.filter((c) => c.status === 'WARN');
|
|
223
|
+
const errors = failures.map((c) => c.message);
|
|
224
|
+
// If critical mode and failures exist, fail entire validation
|
|
225
|
+
const passed = config.critical ? failures.length === 0 : true;
|
|
226
|
+
return {
|
|
227
|
+
passed,
|
|
228
|
+
checks,
|
|
229
|
+
errors,
|
|
230
|
+
warnings: warnings.map((c) => c.message),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Format validation result for console output
|
|
235
|
+
*/
|
|
236
|
+
export function formatValidationResult(result) {
|
|
237
|
+
const lines = [];
|
|
238
|
+
lines.push('\n=== Security Environment Validation ===\n');
|
|
239
|
+
for (const check of result.checks) {
|
|
240
|
+
const symbol = check.status === 'PASS' ? '✓' : check.status === 'WARN' ? '⚠' : '✗';
|
|
241
|
+
lines.push(`${symbol} ${check.name}: ${check.message}`);
|
|
242
|
+
}
|
|
243
|
+
if (result.errors.length > 0) {
|
|
244
|
+
lines.push('\nErrors:');
|
|
245
|
+
for (const error of result.errors) {
|
|
246
|
+
lines.push(` ✗ ${error}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (result.warnings.length > 0) {
|
|
250
|
+
lines.push('\nWarnings:');
|
|
251
|
+
for (const warning of result.warnings) {
|
|
252
|
+
lines.push(` ⚠ ${warning}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
lines.push(`\nValidation Status: ${result.passed ? 'PASSED' : 'FAILED'}\n`);
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Exit with validation error
|
|
260
|
+
*/
|
|
261
|
+
export function exitOnValidationFailure(result, exitCode = 1) {
|
|
262
|
+
if (!result.passed) {
|
|
263
|
+
console.error(formatValidationResult(result));
|
|
264
|
+
process.exit(exitCode);
|
|
265
|
+
}
|
|
266
|
+
}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { MetricsStore } from './metricsStore.js';
|
|
3
|
+
import { createApiRouter } from './api.js';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
export class LeanClaudientDaemon {
|
|
7
|
+
constructor(port = 9831, logPath) {
|
|
8
|
+
this.app = express();
|
|
9
|
+
this.port = port;
|
|
10
|
+
this.logPath = logPath || path.join(process.env.HOME, '.lean-claudient', 'daemon.log');
|
|
11
|
+
this.metricsStore = new MetricsStore();
|
|
12
|
+
this.setupExpress();
|
|
13
|
+
}
|
|
14
|
+
setupExpress() {
|
|
15
|
+
this.app.use(express.json());
|
|
16
|
+
this.app.use('/api', createApiRouter(this.metricsStore));
|
|
17
|
+
this.app.get('/health', (req, res) => {
|
|
18
|
+
res.json({ status: 'ok', uptime: process.uptime(), pid: process.pid });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async start() {
|
|
22
|
+
const logDir = path.dirname(this.logPath);
|
|
23
|
+
if (!fs.existsSync(logDir)) {
|
|
24
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
this.log('Lean Claudient Daemon starting...');
|
|
27
|
+
await this.metricsStore.initialize();
|
|
28
|
+
this.app.listen(this.port, () => {
|
|
29
|
+
this.log(`Daemon listening on port ${this.port}`);
|
|
30
|
+
});
|
|
31
|
+
process.on('SIGTERM', () => this.shutdown());
|
|
32
|
+
process.on('SIGINT', () => this.shutdown());
|
|
33
|
+
await new Promise(() => { });
|
|
34
|
+
}
|
|
35
|
+
async shutdown() {
|
|
36
|
+
this.log('Shutting down daemon...');
|
|
37
|
+
await this.metricsStore.close();
|
|
38
|
+
this.log('Daemon stopped cleanly');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
log(msg) {
|
|
42
|
+
const timestamp = new Date().toISOString();
|
|
43
|
+
const line = `[${timestamp}] ${msg}\n`;
|
|
44
|
+
console.log(line);
|
|
45
|
+
fs.appendFileSync(this.logPath, line);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Validation Schemas
|
|
3
|
+
* Zod schemas for all API inputs and daemon operations
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
/**
|
|
7
|
+
* UUID validation - 36 characters with hyphens
|
|
8
|
+
*/
|
|
9
|
+
export const uuidSchema = z.string().uuid().describe('Valid UUID format');
|
|
10
|
+
/**
|
|
11
|
+
* Session ID schema
|
|
12
|
+
*/
|
|
13
|
+
export const sessionIdSchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.uuid()
|
|
16
|
+
.describe('Session ID must be a valid UUID');
|
|
17
|
+
/**
|
|
18
|
+
* Metrics record schema
|
|
19
|
+
*/
|
|
20
|
+
export const metricsRecordSchema = z.object({
|
|
21
|
+
sessionId: sessionIdSchema,
|
|
22
|
+
timestamp: z.number().int().positive().describe('Unix timestamp'),
|
|
23
|
+
inputTokens: z.number().int().nonnegative().describe('Input tokens used'),
|
|
24
|
+
outputTokens: z.number().int().nonnegative().describe('Output tokens generated'),
|
|
25
|
+
totalTokens: z.number().int().nonnegative().describe('Total tokens (input + output)'),
|
|
26
|
+
cost: z.number().nonnegative().describe('Cost in USD'),
|
|
27
|
+
savedTokens: z
|
|
28
|
+
.number()
|
|
29
|
+
.int()
|
|
30
|
+
.nonnegative()
|
|
31
|
+
.describe('Tokens saved through compression'),
|
|
32
|
+
compressionRatio: z.number().nonnegative().describe('Compression ratio percentage'),
|
|
33
|
+
subagentCount: z.number().int().nonnegative().describe('Number of subagents used'),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Budget status schema
|
|
37
|
+
*/
|
|
38
|
+
export const budgetStatusSchema = z.object({
|
|
39
|
+
sessionId: sessionIdSchema,
|
|
40
|
+
remainingTokens: z.number().int().nonnegative(),
|
|
41
|
+
totalBudget: z.number().int().positive(),
|
|
42
|
+
usedTokens: z.number().int().nonnegative(),
|
|
43
|
+
percentUsed: z.number().min(0).max(100),
|
|
44
|
+
status: z.enum(['ACTIVE', 'WARNING', 'CRITICAL', 'EXCEEDED']),
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Metrics payload schema for POST /api/metrics
|
|
48
|
+
*/
|
|
49
|
+
export const metricsPayloadSchema = z.object({
|
|
50
|
+
sessionId: sessionIdSchema,
|
|
51
|
+
metrics: metricsRecordSchema.omit({ sessionId: true }),
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Checkpoint schema - valid git commit hash or checkpoint identifier
|
|
55
|
+
*/
|
|
56
|
+
export const checkpointSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
checkpoint: z.string().min(1).max(1000).describe('Checkpoint identifier or hash'),
|
|
59
|
+
})
|
|
60
|
+
.strict();
|
|
61
|
+
/**
|
|
62
|
+
* Budget schema for budget updates
|
|
63
|
+
*/
|
|
64
|
+
export const budgetSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
sessionId: sessionIdSchema,
|
|
67
|
+
totalBudget: z.number().int().positive().describe('Total token budget'),
|
|
68
|
+
softLimit: z.number().int().positive().optional().describe('Warning threshold'),
|
|
69
|
+
hardLimit: z.number().int().positive().optional().describe('Absolute maximum'),
|
|
70
|
+
})
|
|
71
|
+
.strict();
|
|
72
|
+
/**
|
|
73
|
+
* Session creation schema
|
|
74
|
+
*/
|
|
75
|
+
export const sessionCreationSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
sessionId: sessionIdSchema,
|
|
78
|
+
budget: z.number().int().positive().describe('Initial token budget'),
|
|
79
|
+
metadata: z.record(z.any()).optional().describe('Custom metadata'),
|
|
80
|
+
})
|
|
81
|
+
.strict();
|
|
82
|
+
/**
|
|
83
|
+
* Query validation schema
|
|
84
|
+
*/
|
|
85
|
+
export const queryParamSchema = z.object({
|
|
86
|
+
sessionId: sessionIdSchema.optional(),
|
|
87
|
+
startTime: z.number().int().nonnegative().optional(),
|
|
88
|
+
endTime: z.number().int().nonnegative().optional(),
|
|
89
|
+
limit: z.number().int().min(1).max(1000).optional(),
|
|
90
|
+
});
|
|
91
|
+
/**
|
|
92
|
+
* Error response schema
|
|
93
|
+
*/
|
|
94
|
+
export const errorResponseSchema = z.object({
|
|
95
|
+
error: z.string(),
|
|
96
|
+
code: z.string().optional(),
|
|
97
|
+
details: z.any().optional(),
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
* Success response schema
|
|
101
|
+
*/
|
|
102
|
+
export const successResponseSchema = z.object({
|
|
103
|
+
success: z.boolean(),
|
|
104
|
+
data: z.any().optional(),
|
|
105
|
+
message: z.string().optional(),
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Health check response schema
|
|
109
|
+
*/
|
|
110
|
+
export const healthCheckSchema = z.object({
|
|
111
|
+
running: z.boolean(),
|
|
112
|
+
uptime: z.number().int().nonnegative(),
|
|
113
|
+
memory: z.number().nonnegative(),
|
|
114
|
+
pid: z.number().int().positive(),
|
|
115
|
+
timestamp: z.string().datetime().optional(),
|
|
116
|
+
version: z.string().optional(),
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* Aggregate metrics schema
|
|
120
|
+
*/
|
|
121
|
+
export const aggregateMetricsSchema = z.object({
|
|
122
|
+
totalInputTokens: z.number().int().nonnegative(),
|
|
123
|
+
totalOutputTokens: z.number().int().nonnegative(),
|
|
124
|
+
totalTokens: z.number().int().nonnegative(),
|
|
125
|
+
totalCost: z.string(),
|
|
126
|
+
totalSavedTokens: z.number().int().nonnegative(),
|
|
127
|
+
averageCompressionRatio: z.number().nonnegative(),
|
|
128
|
+
sessionCount: z.number().int().nonnegative(),
|
|
129
|
+
averageSubagentCount: z.number().int().nonnegative(),
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* Validate UUID in path parameter
|
|
133
|
+
*/
|
|
134
|
+
export function validateUUID(id) {
|
|
135
|
+
try {
|
|
136
|
+
return uuidSchema.parse(id);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Safe parse helper - returns [data, error]
|
|
144
|
+
*/
|
|
145
|
+
export function safeParse(schema, data) {
|
|
146
|
+
try {
|
|
147
|
+
const result = schema.parse(data);
|
|
148
|
+
return [result, null];
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error instanceof z.ZodError) {
|
|
152
|
+
const messages = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
|
|
153
|
+
return [null, messages.join('; ')];
|
|
154
|
+
}
|
|
155
|
+
return [null, String(error)];
|
|
156
|
+
}
|
|
157
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lean-claudient-daemon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Universal status daemon for Lean Claudient: token budget enforcement across CLIs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/service.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"lean-claudient-daemon": "dist/bin/daemon.js"
|
|
9
|
+
},
|
|
10
|
+
"author": "Lean Claudient Contributors",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"homepage": "https://github.com/Claudient/LeanClaudient#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/Claudient/LeanClaudient.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/Claudient/LeanClaudient/issues"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"claude",
|
|
22
|
+
"daemon",
|
|
23
|
+
"token-budget",
|
|
24
|
+
"llm",
|
|
25
|
+
"anthropic"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"dev": "tsc --watch",
|
|
30
|
+
"test": "echo 'tests pending'",
|
|
31
|
+
"test:watch": "echo 'tests pending'",
|
|
32
|
+
"test:coverage": "echo 'tests pending'",
|
|
33
|
+
"lint": "echo 'linter pending'",
|
|
34
|
+
"format": "echo 'formatter pending'",
|
|
35
|
+
"type-check": "tsc --noEmit",
|
|
36
|
+
"start": "node dist/bin/daemon.js"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"express": "^4.18.2",
|
|
40
|
+
"sqlite3": "^5.1.6",
|
|
41
|
+
"commander": "^11.1.0",
|
|
42
|
+
"zod": "^3.22.4"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/express": "^4.17.21",
|
|
46
|
+
"@types/node": "^20.10.0",
|
|
47
|
+
"typescript": "^5.3.2"
|
|
48
|
+
}
|
|
49
|
+
}
|