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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Environment Validation
3
+ * Validates daemon security configuration at startup
4
+ */
5
+
6
+ import { existsSync, accessSync, constants } from 'fs';
7
+ import { dirname } from 'path';
8
+
9
+ // Mock detectSecrets function since core/security is not accessible from daemon
10
+ function detectSecrets(text: string): Array<{ severity: string }> {
11
+ // Placeholder for secrets detection
12
+ return [];
13
+ }
14
+
15
+ export interface ValidationResult {
16
+ passed: boolean;
17
+ checks: {
18
+ name: string;
19
+ status: 'PASS' | 'WARN' | 'FAIL';
20
+ message: string;
21
+ }[];
22
+ errors: string[];
23
+ warnings: string[];
24
+ }
25
+
26
+ /**
27
+ * Check if process environment contains suspicious secrets
28
+ */
29
+ export function checkEnvironmentSecrets(): { status: 'PASS' | 'WARN'; message: string } {
30
+ const envString = JSON.stringify(process.env);
31
+ const findings = detectSecrets(envString);
32
+
33
+ if (findings.length === 0) {
34
+ return { status: 'PASS', message: 'No hardcoded secrets in environment' };
35
+ }
36
+
37
+ const critical = findings.filter((f) => f.severity === 'CRITICAL');
38
+ const high = findings.filter((f) => f.severity === 'HIGH');
39
+
40
+ if (critical.length > 0) {
41
+ return {
42
+ status: 'WARN',
43
+ message: `Found ${critical.length} CRITICAL and ${high.length} HIGH severity secrets in environment`,
44
+ };
45
+ }
46
+
47
+ return {
48
+ status: 'PASS',
49
+ message: `Found ${high.length} potential high-entropy strings (may be expected secrets)`,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Validate daemon port is not privileged
55
+ */
56
+ export function checkDaemonPort(port: number): { status: 'PASS' | 'FAIL'; message: string } {
57
+ if (port < 1024) {
58
+ return {
59
+ status: 'FAIL',
60
+ message: `Port ${port} is privileged (<1024). Use unprivileged port or run as root (not recommended).`,
61
+ };
62
+ }
63
+
64
+ if (port > 65535) {
65
+ return {
66
+ status: 'FAIL',
67
+ message: `Port ${port} is invalid (>65535)`,
68
+ };
69
+ }
70
+
71
+ return {
72
+ status: 'PASS',
73
+ message: `Daemon port ${port} is valid`,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Check if log directory is writable
79
+ */
80
+ export function checkLogDirectory(logDir: string): { status: 'PASS' | 'WARN'; message: string } {
81
+ // Create directory if it doesn't exist
82
+ if (!existsSync(logDir)) {
83
+ try {
84
+ const parentDir = dirname(logDir);
85
+ if (!existsSync(parentDir)) {
86
+ return {
87
+ status: 'WARN',
88
+ message: `Log directory parent doesn't exist: ${parentDir}`,
89
+ };
90
+ }
91
+
92
+ accessSync(parentDir, constants.W_OK);
93
+ return {
94
+ status: 'PASS',
95
+ message: `Log directory can be created: ${logDir}`,
96
+ };
97
+ } catch {
98
+ return {
99
+ status: 'WARN',
100
+ message: `Cannot write to log directory parent: ${dirname(logDir)}`,
101
+ };
102
+ }
103
+ }
104
+
105
+ try {
106
+ accessSync(logDir, constants.W_OK);
107
+ return {
108
+ status: 'PASS',
109
+ message: `Log directory is writable: ${logDir}`,
110
+ };
111
+ } catch {
112
+ return {
113
+ status: 'WARN',
114
+ message: `Log directory is not writable: ${logDir}`,
115
+ };
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check if database path is writable
121
+ */
122
+ export function checkDatabasePath(dbPath: string): { status: 'PASS' | 'WARN'; message: string } {
123
+ const dbDir = dirname(dbPath);
124
+
125
+ if (!existsSync(dbDir)) {
126
+ const parentDir = dirname(dbDir);
127
+ try {
128
+ accessSync(parentDir, constants.W_OK);
129
+ return {
130
+ status: 'PASS',
131
+ message: `Database directory can be created: ${dbDir}`,
132
+ };
133
+ } catch {
134
+ return {
135
+ status: 'WARN',
136
+ message: `Cannot write to database directory parent: ${parentDir}`,
137
+ };
138
+ }
139
+ }
140
+
141
+ try {
142
+ accessSync(dbDir, constants.W_OK);
143
+ return {
144
+ status: 'PASS',
145
+ message: `Database directory is writable: ${dbDir}`,
146
+ };
147
+ } catch {
148
+ return {
149
+ status: 'WARN',
150
+ message: `Database directory is not writable: ${dbDir}`,
151
+ };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Check required environment variables
157
+ */
158
+ export function checkRequiredEnvVars(required: string[]): { status: 'PASS' | 'FAIL'; message: string } {
159
+ const missing = required.filter((key) => !process.env[key]);
160
+
161
+ if (missing.length === 0) {
162
+ return {
163
+ status: 'PASS',
164
+ message: `All required environment variables are set`,
165
+ };
166
+ }
167
+
168
+ return {
169
+ status: 'FAIL',
170
+ message: `Missing environment variables: ${missing.join(', ')}`,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Check for deprecated or insecure environment variables
176
+ */
177
+ export function checkDeprecatedEnvVars(): { status: 'PASS' | 'WARN'; message: string } {
178
+ const deprecated = [
179
+ 'DEBUG',
180
+ 'VERBOSE',
181
+ 'LOG_LEVEL', // prefer structured logging
182
+ ];
183
+
184
+ const found = deprecated.filter((key) => process.env[key]);
185
+
186
+ if (found.length === 0) {
187
+ return {
188
+ status: 'PASS',
189
+ message: 'No deprecated environment variables detected',
190
+ };
191
+ }
192
+
193
+ return {
194
+ status: 'WARN',
195
+ message: `Found deprecated environment variables: ${found.join(', ')}`,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Validate NODE_ENV is set appropriately
201
+ */
202
+ export function checkNodeEnv(): { status: 'PASS' | 'WARN'; message: string } {
203
+ const nodeEnv = process.env.NODE_ENV || 'development';
204
+
205
+ if (nodeEnv === 'production' || nodeEnv === 'staging' || nodeEnv === 'development') {
206
+ return {
207
+ status: 'PASS',
208
+ message: `NODE_ENV is set to: ${nodeEnv}`,
209
+ };
210
+ }
211
+
212
+ return {
213
+ status: 'WARN',
214
+ message: `NODE_ENV has unexpected value: ${nodeEnv}. Should be production, staging, or development.`,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Check memory and resource limits
220
+ */
221
+ export function checkResourceLimits(): { status: 'PASS' | 'WARN'; message: string } {
222
+ const memUsage = process.memoryUsage();
223
+ const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
224
+ const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
225
+
226
+ if (heapTotalMB > 4096) {
227
+ return {
228
+ status: 'WARN',
229
+ message: `Daemon has high heap allocation: ${heapTotalMB}MB (${heapUsedMB}MB used)`,
230
+ };
231
+ }
232
+
233
+ return {
234
+ status: 'PASS',
235
+ message: `Memory usage: ${heapUsedMB}MB / ${heapTotalMB}MB`,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Full validation suite
241
+ */
242
+ export function validateEnvironment(config: {
243
+ port: number;
244
+ logDir: string;
245
+ dbPath: string;
246
+ requiredEnvVars?: string[];
247
+ critical?: boolean;
248
+ }): ValidationResult {
249
+ const checks = [
250
+ { name: 'Environment Secrets', ...checkEnvironmentSecrets() },
251
+ { name: 'Daemon Port', ...checkDaemonPort(config.port) },
252
+ { name: 'Log Directory', ...checkLogDirectory(config.logDir) },
253
+ { name: 'Database Path', ...checkDatabasePath(config.dbPath) },
254
+ { name: 'NODE_ENV', ...checkNodeEnv() },
255
+ { name: 'Deprecated Variables', ...checkDeprecatedEnvVars() },
256
+ { name: 'Resource Limits', ...checkResourceLimits() },
257
+ ];
258
+
259
+ if (config.requiredEnvVars && config.requiredEnvVars.length > 0) {
260
+ checks.push({
261
+ name: 'Required Variables',
262
+ ...checkRequiredEnvVars(config.requiredEnvVars),
263
+ });
264
+ }
265
+
266
+ const failures = checks.filter((c) => c.status === 'FAIL');
267
+ const warnings = checks.filter((c) => c.status === 'WARN');
268
+ const errors = failures.map((c) => c.message);
269
+
270
+ // If critical mode and failures exist, fail entire validation
271
+ const passed = config.critical ? failures.length === 0 : true;
272
+
273
+ return {
274
+ passed,
275
+ checks,
276
+ errors,
277
+ warnings: warnings.map((c) => c.message),
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Format validation result for console output
283
+ */
284
+ export function formatValidationResult(result: ValidationResult): string {
285
+ const lines: string[] = [];
286
+
287
+ lines.push('\n=== Security Environment Validation ===\n');
288
+
289
+ for (const check of result.checks) {
290
+ const symbol = check.status === 'PASS' ? '✓' : check.status === 'WARN' ? '⚠' : '✗';
291
+ lines.push(`${symbol} ${check.name}: ${check.message}`);
292
+ }
293
+
294
+ if (result.errors.length > 0) {
295
+ lines.push('\nErrors:');
296
+ for (const error of result.errors) {
297
+ lines.push(` ✗ ${error}`);
298
+ }
299
+ }
300
+
301
+ if (result.warnings.length > 0) {
302
+ lines.push('\nWarnings:');
303
+ for (const warning of result.warnings) {
304
+ lines.push(` ⚠ ${warning}`);
305
+ }
306
+ }
307
+
308
+ lines.push(`\nValidation Status: ${result.passed ? 'PASSED' : 'FAILED'}\n`);
309
+
310
+ return lines.join('\n');
311
+ }
312
+
313
+ /**
314
+ * Exit with validation error
315
+ */
316
+ export function exitOnValidationFailure(result: ValidationResult, exitCode = 1): void {
317
+ if (!result.passed) {
318
+ console.error(formatValidationResult(result));
319
+ process.exit(exitCode);
320
+ }
321
+ }
package/src/service.ts ADDED
@@ -0,0 +1,62 @@
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
+
7
+ export class LeanClaudientDaemon {
8
+ private app = express();
9
+ private metricsStore: MetricsStore;
10
+ private port: number;
11
+ private logPath: string;
12
+
13
+ constructor(port: number = 9831, logPath?: string) {
14
+ this.port = port;
15
+ this.logPath = logPath || path.join(process.env.HOME!, '.lean-claudient', 'daemon.log');
16
+ this.metricsStore = new MetricsStore();
17
+ this.setupExpress();
18
+ }
19
+
20
+ private setupExpress() {
21
+ this.app.use(express.json());
22
+ this.app.use('/api', createApiRouter(this.metricsStore));
23
+
24
+ this.app.get('/health', (req, res) => {
25
+ res.json({ status: 'ok', uptime: process.uptime(), pid: process.pid });
26
+ });
27
+ }
28
+
29
+ async start() {
30
+ const logDir = path.dirname(this.logPath);
31
+ if (!fs.existsSync(logDir)) {
32
+ fs.mkdirSync(logDir, { recursive: true });
33
+ }
34
+
35
+ this.log('Lean Claudient Daemon starting...');
36
+
37
+ await this.metricsStore.initialize();
38
+
39
+ this.app.listen(this.port, () => {
40
+ this.log(`Daemon listening on port ${this.port}`);
41
+ });
42
+
43
+ process.on('SIGTERM', () => this.shutdown());
44
+ process.on('SIGINT', () => this.shutdown());
45
+
46
+ await new Promise(() => {});
47
+ }
48
+
49
+ private async shutdown() {
50
+ this.log('Shutting down daemon...');
51
+ await this.metricsStore.close();
52
+ this.log('Daemon stopped cleanly');
53
+ process.exit(0);
54
+ }
55
+
56
+ private log(msg: string) {
57
+ const timestamp = new Date().toISOString();
58
+ const line = `[${timestamp}] ${msg}\n`;
59
+ console.log(line);
60
+ fs.appendFileSync(this.logPath, line);
61
+ }
62
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Input Validation Schemas
3
+ * Zod schemas for all API inputs and daemon operations
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ /**
9
+ * UUID validation - 36 characters with hyphens
10
+ */
11
+ export const uuidSchema = z.string().uuid().describe('Valid UUID format');
12
+
13
+ /**
14
+ * Session ID schema
15
+ */
16
+ export const sessionIdSchema = z
17
+ .string()
18
+ .uuid()
19
+ .describe('Session ID must be a valid UUID');
20
+
21
+ /**
22
+ * Metrics record schema
23
+ */
24
+ export const metricsRecordSchema = z.object({
25
+ sessionId: sessionIdSchema,
26
+ timestamp: z.number().int().positive().describe('Unix timestamp'),
27
+ inputTokens: z.number().int().nonnegative().describe('Input tokens used'),
28
+ outputTokens: z.number().int().nonnegative().describe('Output tokens generated'),
29
+ totalTokens: z.number().int().nonnegative().describe('Total tokens (input + output)'),
30
+ cost: z.number().nonnegative().describe('Cost in USD'),
31
+ savedTokens: z
32
+ .number()
33
+ .int()
34
+ .nonnegative()
35
+ .describe('Tokens saved through compression'),
36
+ compressionRatio: z.number().nonnegative().describe('Compression ratio percentage'),
37
+ subagentCount: z.number().int().nonnegative().describe('Number of subagents used'),
38
+ });
39
+
40
+ export type MetricsRecord = z.infer<typeof metricsRecordSchema>;
41
+
42
+ /**
43
+ * Budget status schema
44
+ */
45
+ export const budgetStatusSchema = z.object({
46
+ sessionId: sessionIdSchema,
47
+ remainingTokens: z.number().int().nonnegative(),
48
+ totalBudget: z.number().int().positive(),
49
+ usedTokens: z.number().int().nonnegative(),
50
+ percentUsed: z.number().min(0).max(100),
51
+ status: z.enum(['ACTIVE', 'WARNING', 'CRITICAL', 'EXCEEDED']),
52
+ });
53
+
54
+ export type BudgetStatus = z.infer<typeof budgetStatusSchema>;
55
+
56
+ /**
57
+ * Metrics payload schema for POST /api/metrics
58
+ */
59
+ export const metricsPayloadSchema = z.object({
60
+ sessionId: sessionIdSchema,
61
+ metrics: metricsRecordSchema.omit({ sessionId: true }),
62
+ });
63
+
64
+ export type MetricsPayload = z.infer<typeof metricsPayloadSchema>;
65
+
66
+ /**
67
+ * Checkpoint schema - valid git commit hash or checkpoint identifier
68
+ */
69
+ export const checkpointSchema = z
70
+ .object({
71
+ checkpoint: z.string().min(1).max(1000).describe('Checkpoint identifier or hash'),
72
+ })
73
+ .strict();
74
+
75
+ export type CheckpointPayload = z.infer<typeof checkpointSchema>;
76
+
77
+ /**
78
+ * Budget schema for budget updates
79
+ */
80
+ export const budgetSchema = z
81
+ .object({
82
+ sessionId: sessionIdSchema,
83
+ totalBudget: z.number().int().positive().describe('Total token budget'),
84
+ softLimit: z.number().int().positive().optional().describe('Warning threshold'),
85
+ hardLimit: z.number().int().positive().optional().describe('Absolute maximum'),
86
+ })
87
+ .strict();
88
+
89
+ export type BudgetPayload = z.infer<typeof budgetSchema>;
90
+
91
+ /**
92
+ * Session creation schema
93
+ */
94
+ export const sessionCreationSchema = z
95
+ .object({
96
+ sessionId: sessionIdSchema,
97
+ budget: z.number().int().positive().describe('Initial token budget'),
98
+ metadata: z.record(z.any()).optional().describe('Custom metadata'),
99
+ })
100
+ .strict();
101
+
102
+ export type SessionCreation = z.infer<typeof sessionCreationSchema>;
103
+
104
+ /**
105
+ * Query validation schema
106
+ */
107
+ export const queryParamSchema = z.object({
108
+ sessionId: sessionIdSchema.optional(),
109
+ startTime: z.number().int().nonnegative().optional(),
110
+ endTime: z.number().int().nonnegative().optional(),
111
+ limit: z.number().int().min(1).max(1000).optional(),
112
+ });
113
+
114
+ export type QueryParams = z.infer<typeof queryParamSchema>;
115
+
116
+ /**
117
+ * Error response schema
118
+ */
119
+ export const errorResponseSchema = z.object({
120
+ error: z.string(),
121
+ code: z.string().optional(),
122
+ details: z.any().optional(),
123
+ });
124
+
125
+ /**
126
+ * Success response schema
127
+ */
128
+ export const successResponseSchema = z.object({
129
+ success: z.boolean(),
130
+ data: z.any().optional(),
131
+ message: z.string().optional(),
132
+ });
133
+
134
+ /**
135
+ * Health check response schema
136
+ */
137
+ export const healthCheckSchema = z.object({
138
+ running: z.boolean(),
139
+ uptime: z.number().int().nonnegative(),
140
+ memory: z.number().nonnegative(),
141
+ pid: z.number().int().positive(),
142
+ timestamp: z.string().datetime().optional(),
143
+ version: z.string().optional(),
144
+ });
145
+
146
+ /**
147
+ * Aggregate metrics schema
148
+ */
149
+ export const aggregateMetricsSchema = z.object({
150
+ totalInputTokens: z.number().int().nonnegative(),
151
+ totalOutputTokens: z.number().int().nonnegative(),
152
+ totalTokens: z.number().int().nonnegative(),
153
+ totalCost: z.string(),
154
+ totalSavedTokens: z.number().int().nonnegative(),
155
+ averageCompressionRatio: z.number().nonnegative(),
156
+ sessionCount: z.number().int().nonnegative(),
157
+ averageSubagentCount: z.number().int().nonnegative(),
158
+ });
159
+
160
+ export type AggregateMetrics = z.infer<typeof aggregateMetricsSchema>;
161
+
162
+ /**
163
+ * Validate UUID in path parameter
164
+ */
165
+ export function validateUUID(id: string): string | null {
166
+ try {
167
+ return uuidSchema.parse(id);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Safe parse helper - returns [data, error]
175
+ */
176
+ export function safeParse<T>(
177
+ schema: z.ZodSchema<T>,
178
+ data: unknown
179
+ ): [T | null, string | null] {
180
+ try {
181
+ const result = schema.parse(data);
182
+ return [result, null];
183
+ } catch (error) {
184
+ if (error instanceof z.ZodError) {
185
+ const messages = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
186
+ return [null, messages.join('; ')];
187
+ }
188
+ return [null, String(error)];
189
+ }
190
+ }