cipher-security 2.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.
Files changed (76) hide show
  1. package/bin/cipher.js +566 -0
  2. package/lib/api/billing.js +321 -0
  3. package/lib/api/compliance.js +693 -0
  4. package/lib/api/controls.js +1401 -0
  5. package/lib/api/index.js +49 -0
  6. package/lib/api/marketplace.js +467 -0
  7. package/lib/api/openai-proxy.js +383 -0
  8. package/lib/api/server.js +685 -0
  9. package/lib/autonomous/feedback-loop.js +554 -0
  10. package/lib/autonomous/framework.js +512 -0
  11. package/lib/autonomous/index.js +97 -0
  12. package/lib/autonomous/leaderboard.js +594 -0
  13. package/lib/autonomous/modes/architect.js +412 -0
  14. package/lib/autonomous/modes/blue.js +386 -0
  15. package/lib/autonomous/modes/incident.js +684 -0
  16. package/lib/autonomous/modes/privacy.js +369 -0
  17. package/lib/autonomous/modes/purple.js +294 -0
  18. package/lib/autonomous/modes/recon.js +250 -0
  19. package/lib/autonomous/parallel.js +587 -0
  20. package/lib/autonomous/researcher.js +583 -0
  21. package/lib/autonomous/runner.js +955 -0
  22. package/lib/autonomous/scheduler.js +615 -0
  23. package/lib/autonomous/task-parser.js +127 -0
  24. package/lib/autonomous/validators/forensic.js +266 -0
  25. package/lib/autonomous/validators/osint.js +216 -0
  26. package/lib/autonomous/validators/privacy.js +296 -0
  27. package/lib/autonomous/validators/purple.js +298 -0
  28. package/lib/autonomous/validators/sigma.js +248 -0
  29. package/lib/autonomous/validators/threat-model.js +363 -0
  30. package/lib/benchmark/agent.js +119 -0
  31. package/lib/benchmark/baselines.js +43 -0
  32. package/lib/benchmark/builder.js +143 -0
  33. package/lib/benchmark/config.js +35 -0
  34. package/lib/benchmark/coordinator.js +91 -0
  35. package/lib/benchmark/index.js +20 -0
  36. package/lib/benchmark/llm.js +58 -0
  37. package/lib/benchmark/models.js +137 -0
  38. package/lib/benchmark/reporter.js +103 -0
  39. package/lib/benchmark/runner.js +103 -0
  40. package/lib/benchmark/sandbox.js +96 -0
  41. package/lib/benchmark/scorer.js +32 -0
  42. package/lib/benchmark/solver.js +166 -0
  43. package/lib/benchmark/tools.js +62 -0
  44. package/lib/bot/bot.js +238 -0
  45. package/lib/brand.js +105 -0
  46. package/lib/commands.js +100 -0
  47. package/lib/complexity.js +377 -0
  48. package/lib/config.js +213 -0
  49. package/lib/gateway/client.js +309 -0
  50. package/lib/gateway/commands.js +991 -0
  51. package/lib/gateway/config-validate.js +109 -0
  52. package/lib/gateway/gateway.js +367 -0
  53. package/lib/gateway/index.js +62 -0
  54. package/lib/gateway/mode.js +309 -0
  55. package/lib/gateway/plugins.js +222 -0
  56. package/lib/gateway/prompt.js +214 -0
  57. package/lib/mcp/server.js +262 -0
  58. package/lib/memory/compressor.js +425 -0
  59. package/lib/memory/engine.js +763 -0
  60. package/lib/memory/evolution.js +668 -0
  61. package/lib/memory/index.js +58 -0
  62. package/lib/memory/orchestrator.js +506 -0
  63. package/lib/memory/retriever.js +515 -0
  64. package/lib/memory/synthesizer.js +333 -0
  65. package/lib/pipeline/async-scanner.js +510 -0
  66. package/lib/pipeline/binary-analysis.js +1043 -0
  67. package/lib/pipeline/dom-xss-scanner.js +435 -0
  68. package/lib/pipeline/github-actions.js +792 -0
  69. package/lib/pipeline/index.js +124 -0
  70. package/lib/pipeline/osint.js +498 -0
  71. package/lib/pipeline/sarif.js +373 -0
  72. package/lib/pipeline/scanner.js +880 -0
  73. package/lib/pipeline/template-manager.js +525 -0
  74. package/lib/pipeline/xss-scanner.js +353 -0
  75. package/lib/setup-wizard.js +288 -0
  76. package/package.json +31 -0
@@ -0,0 +1,991 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * commands.js — Node.js handler functions for all 22 bridge-mode commands.
7
+ *
8
+ * Each handler accepts a plain args object and returns a plain JS object.
9
+ * No Rich formatting, no Typer framework — just data. The rendering
10
+ * layer stays in cipher.js's formatBridgeResult().
11
+ *
12
+ * All module imports are lazy (dynamic import()) to preserve cold start.
13
+ *
14
+ * @module gateway/commands
15
+ */
16
+
17
+ import { readFileSync, existsSync, readdirSync, writeFileSync, statSync } from 'node:fs';
18
+ import { join, dirname, resolve } from 'node:path';
19
+ import { execSync } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { homedir } from 'node:os';
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ const debug = process.env.CIPHER_DEBUG === '1'
27
+ ? (/** @type {string} */ msg) => process.stderr.write(`[bridge:node] commands: ${msg}\n`)
28
+ : () => {};
29
+
30
+ /**
31
+ * Default memory directory — mirrors Python's ~/.cipher/memory/default.
32
+ * @returns {string}
33
+ */
34
+ function defaultMemoryDir() {
35
+ return join(homedir(), '.cipher', 'memory', 'default');
36
+ }
37
+
38
+ /**
39
+ * Resolve the repository root by walking up from this file.
40
+ * @returns {string}
41
+ */
42
+ function findRepoRoot() {
43
+ let dir = resolve(__dirname, '..', '..');
44
+ for (let i = 0; i < 10; i++) {
45
+ if (existsSync(join(dir, 'skills')) && (existsSync(join(dir, 'cli')) || existsSync(join(dir, 'package.json')))) {
46
+ return dir;
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ }
52
+ return resolve(__dirname, '..', '..');
53
+ }
54
+
55
+ /**
56
+ * Read package.json version from the CLI package.
57
+ * @returns {string}
58
+ */
59
+ function readVersion() {
60
+ try {
61
+ const pkgPath = join(__dirname, '..', '..', 'package.json');
62
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
63
+ return pkg.version || 'unknown';
64
+ } catch {
65
+ return 'unknown';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve the skills directory path.
71
+ * @returns {string}
72
+ */
73
+ function skillsDir() {
74
+ const root = findRepoRoot();
75
+ return join(root, 'skills');
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Memory-backed handlers (8)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Search cross-session memory.
84
+ * @param {object} args
85
+ * @param {string} args.query
86
+ * @param {string} [args.engagement]
87
+ * @param {number} [args.limit]
88
+ * @returns {Promise<object>}
89
+ */
90
+ export async function handleSearch(args = {}) {
91
+ const { AdaptiveRetriever, CipherMemory } = await import('../memory/index.js');
92
+ const memory = new CipherMemory(defaultMemoryDir());
93
+ try {
94
+ const retriever = new AdaptiveRetriever(memory);
95
+ const results = retriever.retrieve(
96
+ args.query || '',
97
+ args.engagement || '',
98
+ args.limit || 10,
99
+ );
100
+ debug(`search: ${results.length} results for "${args.query}"`);
101
+ return {
102
+ query: args.query || '',
103
+ count: results.length,
104
+ results: results.map(r => ({
105
+ content: r.content,
106
+ type: r.memoryType || '',
107
+ severity: r.severity || '',
108
+ targets: r.targets || [],
109
+ mitre: r.mitreAttack || [],
110
+ created: r.createdAt || '',
111
+ })),
112
+ };
113
+ } finally {
114
+ memory.close();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Store finding/IOC/TTP in memory.
120
+ * @param {object} args
121
+ * @param {string} args.content
122
+ * @param {string} [args.type]
123
+ * @param {string} [args.severity]
124
+ * @param {string} [args.engagement]
125
+ * @param {string[]} [args.tags]
126
+ * @returns {Promise<object>}
127
+ */
128
+ export async function handleStore(args = {}) {
129
+ const { CipherMemory, MemoryEntry, MemoryType } = await import('../memory/index.js');
130
+ const memory = new CipherMemory(defaultMemoryDir());
131
+ try {
132
+ const typeStr = args.type || 'note';
133
+ const entry = new MemoryEntry({
134
+ content: args.content || '',
135
+ memoryType: Object.values(MemoryType).includes(typeStr) ? typeStr : MemoryType.NOTE,
136
+ severity: args.severity || '',
137
+ engagementId: args.engagement || '',
138
+ tags: Array.isArray(args.tags)
139
+ ? args.tags
140
+ : (typeof args.tags === 'string' && args.tags ? args.tags.split(',') : []),
141
+ });
142
+ const entryId = memory.store(entry);
143
+ debug(`store: id=${entryId} type=${typeStr}`);
144
+ return { id: entryId, type: typeStr, stored: true };
145
+ } finally {
146
+ memory.close();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Show memory and platform statistics.
152
+ * @returns {Promise<object>}
153
+ */
154
+ export async function handleStats(args = {}) {
155
+ const { CipherMemory } = await import('../memory/index.js');
156
+ const memory = new CipherMemory(defaultMemoryDir());
157
+ try {
158
+ const memStats = memory.stats();
159
+ const version = readVersion();
160
+ const sDir = skillsDir();
161
+ let skillCount = 0;
162
+ let domainCount = 0;
163
+ let scriptCount = 0;
164
+
165
+ if (existsSync(sDir)) {
166
+ try {
167
+ // Count SKILL.md files recursively
168
+ skillCount = countFiles(sDir, 'SKILL.md');
169
+ // Count top-level domain directories
170
+ domainCount = readdirSync(sDir).filter(d => {
171
+ try {
172
+ return !d.startsWith('.') && statSync(join(sDir, d)).isDirectory();
173
+ } catch { return false; }
174
+ }).length;
175
+ // Count script files recursively
176
+ scriptCount = countFiles(sDir, '*.py', 'scripts');
177
+ } catch {
178
+ // Skills dir unreadable
179
+ }
180
+ }
181
+
182
+ debug(`stats: version=${version} memory=${JSON.stringify(memStats)}`);
183
+ return {
184
+ version,
185
+ memory: memStats,
186
+ skills: { total: skillCount, domains: domainCount, scripts: scriptCount },
187
+ };
188
+ } finally {
189
+ memory.close();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Score response quality (PRM evaluation).
195
+ * @param {object} args
196
+ * @param {string} args.query
197
+ * @param {string} args.response
198
+ * @param {string} [args.mode]
199
+ * @returns {Promise<object>}
200
+ */
201
+ export async function handleScore(args = {}) {
202
+ const { ResponseScorer } = await import('../memory/index.js');
203
+ const scorer = new ResponseScorer();
204
+ const scored = scorer.score(
205
+ args.query || '',
206
+ args.response || '',
207
+ args.mode || '',
208
+ );
209
+ debug(`score: ${scored.score}`);
210
+ return {
211
+ score: scored.score,
212
+ votes: scored.votes,
213
+ feedback: scored.feedback,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Export memory database to JSON.
219
+ * @param {object} args
220
+ * @param {string} [args.output]
221
+ * @param {string} [args.engagement]
222
+ * @returns {Promise<object>}
223
+ */
224
+ export async function handleMemoryExport(args = {}) {
225
+ const { CipherMemory } = await import('../memory/index.js');
226
+ const memory = new CipherMemory(defaultMemoryDir());
227
+ try {
228
+ const results = memory.search('', {}, 10000);
229
+ let entries = results.map(r => (typeof r.toDict === 'function' ? r.toDict() : r));
230
+ if (args.engagement) {
231
+ entries = entries.filter(e =>
232
+ (e.engagement_id || e.engagementId) === args.engagement
233
+ );
234
+ }
235
+ const output = args.output || 'cipher-memory-export.json';
236
+ const data = { entries, count: entries.length };
237
+ writeFileSync(output, JSON.stringify(data, null, 2), 'utf-8');
238
+ debug(`memory-export: ${entries.length} entries → ${output}`);
239
+ return { exported: entries.length, file: output };
240
+ } finally {
241
+ memory.close();
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Import memory from JSON backup.
247
+ * @param {object} args
248
+ * @param {string} args.file
249
+ * @returns {Promise<object>}
250
+ */
251
+ export async function handleMemoryImport(args = {}) {
252
+ const file = args.file;
253
+ if (!file || !existsSync(file)) {
254
+ return { error: true, message: `File not found: ${file || '(none)'}` };
255
+ }
256
+ const { CipherMemory, MemoryEntry } = await import('../memory/index.js');
257
+ const memory = new CipherMemory(defaultMemoryDir());
258
+ try {
259
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
260
+ const entries = raw.entries || (Array.isArray(raw) ? raw : []);
261
+ let imported = 0;
262
+ for (const entryData of entries) {
263
+ try {
264
+ const entry = MemoryEntry.fromDict(entryData);
265
+ memory.store(entry);
266
+ imported++;
267
+ } catch {
268
+ // Skip invalid entries
269
+ }
270
+ }
271
+ debug(`memory-import: ${imported}/${entries.length} from ${file}`);
272
+ return { imported, total: entries.length, file };
273
+ } finally {
274
+ memory.close();
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Trigger memory ingestion pipeline.
280
+ * @returns {Promise<object>}
281
+ */
282
+ export async function handleIngest(args = {}) {
283
+ const { CipherMemory } = await import('../memory/index.js');
284
+ const memory = new CipherMemory(defaultMemoryDir());
285
+ try {
286
+ const stats = memory.consolidate();
287
+ debug('ingest: consolidation complete');
288
+ return { ingested: true, ...stats };
289
+ } finally {
290
+ memory.close();
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Config check + memory stats + backend reachability.
296
+ * @returns {Promise<object>}
297
+ */
298
+ export async function handleStatus(args = {}) {
299
+ const { loadConfig, configExists } = await import('../config.js');
300
+ const { validateConfig } = await import('./config-validate.js');
301
+
302
+ const hasConfig = configExists();
303
+ let configValid = false;
304
+ let backend = 'unknown';
305
+
306
+ if (hasConfig) {
307
+ try {
308
+ const raw = loadConfig();
309
+ const config = validateConfig(raw);
310
+ configValid = true;
311
+ backend = config.backend;
312
+ } catch {
313
+ // Config present but invalid
314
+ }
315
+ }
316
+
317
+ // Memory stats
318
+ let memStats = null;
319
+ try {
320
+ const { CipherMemory } = await import('../memory/index.js');
321
+ const memory = new CipherMemory(defaultMemoryDir());
322
+ memStats = memory.stats();
323
+ memory.close();
324
+ } catch {
325
+ // Memory unavailable
326
+ }
327
+
328
+ const status = configValid ? 'ready' : (hasConfig ? 'misconfigured' : 'unconfigured');
329
+ debug(`status: ${status} backend=${backend}`);
330
+ return {
331
+ status,
332
+ backend,
333
+ config: { exists: hasConfig, valid: configValid },
334
+ memory: memStats,
335
+ };
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Pipeline-backed handlers (6)
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * Analyze git diff for security issues.
344
+ * @param {object} args
345
+ * @param {string} args.file - Diff file path or diff text content
346
+ * @returns {Promise<object>}
347
+ */
348
+ export async function handleDiff(args = {}) {
349
+ const { SecurityDiffAnalyzer } = await import('../pipeline/index.js');
350
+ const analyzer = new SecurityDiffAnalyzer();
351
+
352
+ let diffText = args.file || '';
353
+ // If it looks like a file path (no newlines, exists on disk), read it
354
+ if (diffText && !diffText.includes('\n') && existsSync(diffText)) {
355
+ diffText = readFileSync(diffText, 'utf-8');
356
+ }
357
+
358
+ const analysis = analyzer.analyzeDiff(diffText);
359
+ const riskVal = analysis.riskLevel?.value ?? analysis.riskLevel ?? 'info';
360
+
361
+ debug(`diff: risk=${riskVal} files=${analysis.filesChanged?.length}`);
362
+ return {
363
+ risk_level: riskVal,
364
+ files_changed: analysis.filesChanged?.length ?? 0,
365
+ auth_changes: analysis.authChanges?.length ?? 0,
366
+ sql_changes: analysis.sqlChanges?.length ?? 0,
367
+ crypto_changes: analysis.cryptoChanges?.length ?? 0,
368
+ secrets_found: analysis.secrets?.length ?? 0,
369
+ has_security_findings: analysis.hasSecurityFindings ?? false,
370
+ summary: analysis.summary || '',
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Generate GitHub Actions security workflow.
376
+ * @param {object} args
377
+ * @param {string} [args.profile]
378
+ * @returns {Promise<object>}
379
+ */
380
+ export async function handleWorkflow(args = {}) {
381
+ const { WorkflowGenerator } = await import('../pipeline/index.js');
382
+ const gen = new WorkflowGenerator();
383
+ const workflow = gen.generateWorkflow(args.profile || 'pentest');
384
+ debug('workflow: generated');
385
+ return { workflow };
386
+ }
387
+
388
+ /**
389
+ * Convert scan results to SARIF v2.1.0.
390
+ * @param {object} args
391
+ * @param {string} args.input - Scan results JSON file path
392
+ * @param {string} [args.output]
393
+ * @returns {Promise<object>}
394
+ */
395
+ export async function handleSarif(args = {}) {
396
+ const { SarifReport } = await import('../pipeline/index.js');
397
+ const input = args.input;
398
+ if (!input || !existsSync(input)) {
399
+ return { error: true, message: `Input file not found: ${input || '(none)'}` };
400
+ }
401
+
402
+ const data = JSON.parse(readFileSync(input, 'utf-8'));
403
+ const report = new SarifReport();
404
+ const findings = data.findings || (Array.isArray(data) ? data : []);
405
+
406
+ for (const finding of findings) {
407
+ report.addFinding(finding);
408
+ }
409
+
410
+ const output = args.output || 'cipher-results.sarif';
411
+ report.write(output);
412
+ const summary = report.summary();
413
+
414
+ debug(`sarif: ${findings.length} findings → ${output}`);
415
+ return { file: output, findings: findings.length, summary };
416
+ }
417
+
418
+ /**
419
+ * Run OSINT investigation (domain/IP).
420
+ * @param {object} args
421
+ * @param {string} args.target
422
+ * @param {string} [args.type]
423
+ * @returns {Promise<object>}
424
+ */
425
+ export async function handleOsint(args = {}) {
426
+ const { OSINTPipeline } = await import('../pipeline/index.js');
427
+ const pipeline = new OSINTPipeline();
428
+ const target = args.target || '';
429
+ const type = args.type || 'domain';
430
+
431
+ const results = type === 'ip'
432
+ ? pipeline.investigateIp(target)
433
+ : pipeline.investigateDomain(target);
434
+
435
+ const data = {
436
+ target,
437
+ type,
438
+ results: results.map(r => (typeof r.toDict === 'function' ? r.toDict() : r)),
439
+ summary: pipeline.summary(),
440
+ };
441
+ debug(`osint: ${target} (${type}) → ${results.length} results`);
442
+ return data;
443
+ }
444
+
445
+ /**
446
+ * List all skill domains.
447
+ * @returns {Promise<object>}
448
+ */
449
+ export async function handleDomains(args = {}) {
450
+ const sDir = skillsDir();
451
+ if (!existsSync(sDir)) {
452
+ return { domains: {}, total_domains: 0, total_techniques: 0 };
453
+ }
454
+
455
+ const domainMap = {};
456
+ const entries = readdirSync(sDir).sort();
457
+ for (const d of entries) {
458
+ const dirPath = join(sDir, d);
459
+ try {
460
+ if (d.startsWith('.') || !statSync(dirPath).isDirectory()) continue;
461
+ } catch { continue; }
462
+
463
+ const techniquesDir = join(dirPath, 'techniques');
464
+ if (existsSync(techniquesDir)) {
465
+ try {
466
+ const techniques = readdirSync(techniquesDir).filter(t => {
467
+ try {
468
+ return statSync(join(techniquesDir, t)).isDirectory();
469
+ } catch { return false; }
470
+ });
471
+ domainMap[d] = techniques.length;
472
+ } catch {
473
+ domainMap[d] = 0;
474
+ }
475
+ }
476
+ }
477
+
478
+ const totalTechniques = Object.values(domainMap).reduce((a, b) => a + b, 0);
479
+ debug(`domains: ${Object.keys(domainMap).length} domains, ${totalTechniques} techniques`);
480
+ return {
481
+ domains: domainMap,
482
+ total_domains: Object.keys(domainMap).length,
483
+ total_techniques: totalTechniques,
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Search skills by keyword.
489
+ * @param {object} args
490
+ * @param {string} args.query
491
+ * @param {number} [args.limit]
492
+ * @returns {Promise<object>}
493
+ */
494
+ export async function handleSkills(args = {}) {
495
+ const sDir = skillsDir();
496
+ const query = (args.query || '').toLowerCase();
497
+ const limit = args.limit || 10;
498
+ const results = [];
499
+
500
+ if (!existsSync(sDir)) {
501
+ return { query: args.query || '', count: 0, results: [] };
502
+ }
503
+
504
+ // Walk skills dir looking for SKILL.md files
505
+ const walk = (dir, relBase) => {
506
+ if (results.length >= limit) return;
507
+ try {
508
+ const entries = readdirSync(dir);
509
+ for (const entry of entries) {
510
+ if (results.length >= limit) return;
511
+ const fullPath = join(dir, entry);
512
+ try {
513
+ if (statSync(fullPath).isDirectory()) {
514
+ walk(fullPath, join(relBase, entry));
515
+ } else if (entry === 'SKILL.md') {
516
+ const name = dirname(join(relBase, entry)).split('/').pop() || '';
517
+ const rel = dirname(join(relBase, entry));
518
+ if (query === '' || name.toLowerCase().includes(query) || rel.toLowerCase().includes(query)) {
519
+ results.push({ name, path: rel });
520
+ }
521
+ }
522
+ } catch { /* skip unreadable entries */ }
523
+ }
524
+ } catch { /* skip unreadable dirs */ }
525
+ };
526
+
527
+ walk(sDir, '');
528
+ debug(`skills: "${query}" → ${results.length} results`);
529
+ return { query: args.query || '', count: results.length, results };
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Self-contained handlers (4)
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /**
537
+ * Read version from package.json.
538
+ * @returns {Promise<object>}
539
+ */
540
+ export async function handleVersion(args = {}) {
541
+ return { version: readVersion() };
542
+ }
543
+
544
+ /**
545
+ * Comprehensive health check.
546
+ * @returns {Promise<object>}
547
+ */
548
+ export async function handleDoctor(args = {}) {
549
+ const checks = [];
550
+
551
+ // Node.js version
552
+ checks.push({
553
+ name: 'Node.js',
554
+ status: 'ok',
555
+ detail: process.version,
556
+ });
557
+
558
+ // npm version
559
+ try {
560
+ const npmVer = execSync('npm --version', { encoding: 'utf-8', timeout: 5000 }).trim();
561
+ checks.push({ name: 'npm', status: 'ok', detail: `v${npmVer}` });
562
+ } catch {
563
+ checks.push({ name: 'npm', status: 'missing', detail: 'npm not found' });
564
+ }
565
+
566
+ // External tools (optional)
567
+ for (const tool of ['nuclei', 'katana', 'ollama']) {
568
+ try {
569
+ execSync(`which ${tool}`, { encoding: 'utf-8', timeout: 3000 });
570
+ checks.push({ name: tool, status: 'ok', detail: 'installed' });
571
+ } catch {
572
+ checks.push({ name: tool, status: 'optional', detail: `${tool} not found — optional` });
573
+ }
574
+ }
575
+
576
+ // Config validity
577
+ try {
578
+ const { loadConfig, configExists } = await import('../config.js');
579
+ const { validateConfig } = await import('./config-validate.js');
580
+
581
+ if (configExists()) {
582
+ const raw = loadConfig();
583
+ const config = validateConfig(raw);
584
+ checks.push({
585
+ name: 'Config',
586
+ status: 'ok',
587
+ detail: `backend=${config.backend} model=${config[`${config.backend}_model`] || 'default'}`,
588
+ });
589
+ } else {
590
+ checks.push({
591
+ name: 'Config',
592
+ status: 'missing',
593
+ detail: 'No config found — run: cipher setup',
594
+ });
595
+ }
596
+ } catch (err) {
597
+ checks.push({
598
+ name: 'Config',
599
+ status: 'error',
600
+ detail: err.message?.split('\n')[0] || 'Config validation failed',
601
+ });
602
+ }
603
+
604
+ // Memory database health
605
+ try {
606
+ const { CipherMemory } = await import('../memory/index.js');
607
+ const memory = new CipherMemory(defaultMemoryDir());
608
+ const stats = memory.stats();
609
+ memory.close();
610
+ checks.push({
611
+ name: 'Memory',
612
+ status: 'ok',
613
+ detail: `total=${stats.total ?? 0} active=${stats.active ?? 0} archived=${stats.archived ?? 0}`,
614
+ });
615
+ } catch (err) {
616
+ checks.push({
617
+ name: 'Memory',
618
+ status: 'error',
619
+ detail: err.message || 'Memory database unavailable',
620
+ });
621
+ }
622
+
623
+ const healthy = checks.every(c => c.status === 'ok' || c.status === 'optional');
624
+ const summary = `${checks.filter(c => c.status === 'ok').length}/${checks.length} checks passed`;
625
+
626
+ debug(`doctor: ${summary}`);
627
+ return { checks, healthy, summary };
628
+ }
629
+
630
+ /**
631
+ * Plugin management.
632
+ * @param {object} args
633
+ * @param {string} [args.action] - 'list', 'install', 'remove'
634
+ * @returns {Promise<object>}
635
+ */
636
+ export async function handlePlugin(args = {}) {
637
+ const { PluginManager } = await import('./plugins.js');
638
+ const pm = new PluginManager();
639
+ const action = args.action || 'list';
640
+
641
+ if (action === 'list') {
642
+ const plugins = pm.plugins;
643
+ return {
644
+ count: plugins.length,
645
+ plugins: plugins.map(p => ({
646
+ name: p.name,
647
+ version: p.version,
648
+ description: p.description,
649
+ author: p.author,
650
+ modes: p.modes,
651
+ priority: p.priority,
652
+ source: p.source,
653
+ })),
654
+ };
655
+ }
656
+
657
+ // install/remove are placeholder — real implementation depends on registry
658
+ return {
659
+ error: true,
660
+ message: `Plugin action '${action}' not yet implemented. Only 'list' is available.`,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * Non-streaming query path — delegates to Gateway.send().
666
+ * @param {object} args
667
+ * @param {string} args.message
668
+ * @param {Array<{role: string, content: string}>} [args.history]
669
+ * @returns {Promise<object>}
670
+ */
671
+ export async function handleQuery(args = {}) {
672
+ const { Gateway } = await import('./gateway.js');
673
+ const gateway = new Gateway();
674
+ const response = await gateway.send(args.message || '', args.history);
675
+ debug('query: response received');
676
+ return { response, mode: 'auto' };
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // Stub handlers (4)
681
+ // ---------------------------------------------------------------------------
682
+
683
+ /**
684
+ * @returns {Promise<object>}
685
+ */
686
+ export async function handleLeaderboard(args = {}) {
687
+ return {
688
+ error: true,
689
+ message: 'Leaderboard requires autonomous framework (available after S04). Use Python bridge: cipher leaderboard --bridge',
690
+ };
691
+ }
692
+
693
+ /**
694
+ * @returns {Promise<object>}
695
+ */
696
+ export async function handleFeedback(args = {}) {
697
+ return {
698
+ error: true,
699
+ message: 'Feedback loop requires autonomous framework (available after S04). Use Python bridge: cipher feedback --bridge',
700
+ };
701
+ }
702
+
703
+ /**
704
+ * @returns {Promise<object>}
705
+ */
706
+ export async function handleMarketplace(args = {}) {
707
+ return {
708
+ error: true,
709
+ message: 'Marketplace requires api/marketplace module (available after S05). Use Python bridge: cipher marketplace --bridge',
710
+ };
711
+ }
712
+
713
+ /**
714
+ * @returns {Promise<object>}
715
+ */
716
+ export async function handleCompliance(args = {}) {
717
+ return {
718
+ error: true,
719
+ message: 'Compliance requires api/compliance module (available after S05). Use Python bridge: cipher compliance --bridge',
720
+ };
721
+ }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // Helpers
725
+ // ---------------------------------------------------------------------------
726
+
727
+ /**
728
+ * Recursively count files matching a filename pattern.
729
+ * @param {string} dir
730
+ * @param {string} filename - exact filename (e.g. 'SKILL.md') or glob-like '*.py'
731
+ * @param {string} [subdir] - optional subdirectory filter (only count in dirs named this)
732
+ * @returns {number}
733
+ */
734
+ function countFiles(dir, filename, subdir) {
735
+ let count = 0;
736
+ const isGlob = filename.startsWith('*.');
737
+ const ext = isGlob ? filename.slice(1) : null;
738
+
739
+ const walk = (d) => {
740
+ try {
741
+ const entries = readdirSync(d);
742
+ for (const entry of entries) {
743
+ const fullPath = join(d, entry);
744
+ try {
745
+ const st = statSync(fullPath);
746
+ if (st.isDirectory()) {
747
+ if (!subdir || entry === subdir || d !== dir) {
748
+ walk(fullPath);
749
+ }
750
+ } else if (st.isFile()) {
751
+ if (isGlob ? entry.endsWith(ext) : entry === filename) {
752
+ if (!subdir || dirname(fullPath).split('/').includes(subdir)) {
753
+ count++;
754
+ }
755
+ }
756
+ }
757
+ } catch { /* skip unreadable entries */ }
758
+ }
759
+ } catch { /* skip unreadable dirs */ }
760
+ };
761
+
762
+ walk(dir);
763
+ return count;
764
+ }
765
+
766
+ // ---------------------------------------------------------------------------
767
+ // Scan command — delegates to Node.js NucleiRunner
768
+ // ---------------------------------------------------------------------------
769
+
770
+ export async function handleScan(args = {}) {
771
+ const target = Array.isArray(args) ? args.find(a => !a.startsWith('-')) : args.target;
772
+ if (!target) {
773
+ return { error: true, message: 'Usage: cipher scan <target> [--profile <profile>]' };
774
+ }
775
+ try {
776
+ const { NucleiRunner, ScanProfile } = await import('../pipeline/scanner.js');
777
+ const runner = new NucleiRunner();
778
+ const profileArg = Array.isArray(args) ? (args.find((a, i) => args[i - 1] === '--profile') || 'standard') : (args.profile || 'standard');
779
+ const result = await runner.scan(target, { profile: ScanProfile.fromDomain(profileArg) });
780
+ return {
781
+ output: JSON.stringify({
782
+ target,
783
+ profile: profileArg,
784
+ findings: (result.findings || []).length,
785
+ status: 'completed',
786
+ }, null, 2),
787
+ };
788
+ } catch (err) {
789
+ return { error: true, message: `Scan failed: ${err.message}` };
790
+ }
791
+ }
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Dashboard command — stub for Node.js TUI (Textual TUI not ported)
795
+ // ---------------------------------------------------------------------------
796
+
797
+ export async function handleDashboard() {
798
+ return {
799
+ output: JSON.stringify({
800
+ status: 'info',
801
+ message: 'Dashboard TUI is not yet available in Node.js mode. Use `cipher status` for system status or `cipher api` for the REST API.',
802
+ }, null, 2),
803
+ };
804
+ }
805
+
806
+ // ---------------------------------------------------------------------------
807
+ // Web command — delegates to API server
808
+ // ---------------------------------------------------------------------------
809
+
810
+ export async function handleWeb(args = {}) {
811
+ return {
812
+ output: JSON.stringify({
813
+ status: 'info',
814
+ message: 'The web interface has been consolidated into `cipher api`. Use `cipher api --no-auth --port 8443` to start the REST API server.',
815
+ }, null, 2),
816
+ };
817
+ }
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // Setup Signal command — Signal bot configuration
821
+ // ---------------------------------------------------------------------------
822
+
823
+ export async function handleSetupSignal() {
824
+ const clack = await import('@clack/prompts');
825
+ const { execSync } = await import('node:child_process');
826
+ const { writeConfig, loadConfig, configExists } = await import('../config.js');
827
+ const brand = await import('../brand.js');
828
+
829
+ brand.banner();
830
+ clack.intro('Signal Bot Setup');
831
+
832
+ // Step 1: Check if signal-cli-rest-api is running
833
+ let signalService = 'http://localhost:8080';
834
+ let apiHealthy = false;
835
+
836
+ clack.log.step('Checking for signal-cli-rest-api...');
837
+
838
+ try {
839
+ const resp = execSync(`curl -sf ${signalService}/v1/about`, { encoding: 'utf-8', timeout: 5000 });
840
+ const info = JSON.parse(resp);
841
+ clack.log.success(`Signal API running (v${info.version}, mode: ${info.mode})`);
842
+ apiHealthy = true;
843
+ } catch {
844
+ clack.log.warn('Signal API not detected at localhost:8080');
845
+ clack.log.info(
846
+ 'Start it with Docker:\n\n' +
847
+ ' docker compose --profile signal up -d signal-api\n\n' +
848
+ 'Or manually:\n\n' +
849
+ ' docker run -d --name signal-api \\\n' +
850
+ ' -p 8080:8080 -e MODE=json-rpc \\\n' +
851
+ ' -v signal-data:/home/.local/share/signal-cli \\\n' +
852
+ ' bbernhard/signal-cli-rest-api:0.98'
853
+ );
854
+
855
+ const customUrl = await clack.text({
856
+ message: 'Signal API URL (or press Enter to skip)',
857
+ placeholder: 'http://localhost:8080',
858
+ defaultValue: '',
859
+ });
860
+ if (clack.isCancel(customUrl)) { clack.outro('Cancelled.'); process.exit(0); }
861
+
862
+ if (customUrl) {
863
+ signalService = customUrl;
864
+ try {
865
+ const resp = execSync(`curl -sf ${signalService}/v1/about`, { encoding: 'utf-8', timeout: 5000 });
866
+ clack.log.success('Signal API reachable.');
867
+ apiHealthy = true;
868
+ } catch {
869
+ clack.log.error('Cannot reach Signal API at that URL.');
870
+ clack.outro('Fix the Signal API first, then run cipher setup-signal again.');
871
+ process.exit(1);
872
+ }
873
+ } else {
874
+ clack.outro('Start the Signal API first, then run cipher setup-signal again.');
875
+ process.exit(1);
876
+ }
877
+ }
878
+
879
+ // Step 2: Check registered phone numbers
880
+ let phoneNumber = '';
881
+ try {
882
+ const accounts = JSON.parse(execSync(`curl -sf ${signalService}/v1/accounts`, { encoding: 'utf-8', timeout: 5000 }));
883
+ if (accounts.length > 0) {
884
+ if (accounts.length === 1) {
885
+ phoneNumber = accounts[0];
886
+ clack.log.success(`Registered number: ${phoneNumber}`);
887
+ } else {
888
+ const selected = await clack.select({
889
+ message: 'Select the phone number for the bot',
890
+ options: accounts.map(a => ({ value: a, label: a })),
891
+ });
892
+ if (clack.isCancel(selected)) { clack.outro('Cancelled.'); process.exit(0); }
893
+ phoneNumber = selected;
894
+ }
895
+ } else {
896
+ clack.log.warn('No phone numbers registered with signal-cli.');
897
+ clack.log.info(
898
+ 'Register a number:\n\n' +
899
+ ` curl -X POST ${signalService}/v1/register/<YOUR_NUMBER>\n` +
900
+ ` curl -X POST ${signalService}/v1/verify/<YOUR_NUMBER> -d '{"token":"<CODE>"}'`
901
+ );
902
+ clack.outro('Register a number first, then run cipher setup-signal again.');
903
+ process.exit(1);
904
+ }
905
+ } catch (err) {
906
+ clack.log.error('Failed to query accounts: ' + err.message);
907
+ process.exit(1);
908
+ }
909
+
910
+ // Step 3: Whitelist
911
+ const whitelistInput = await clack.text({
912
+ message: 'Allowed phone numbers (comma-separated, E.164 format)',
913
+ placeholder: '+15551234567,+15559876543',
914
+ defaultValue: '',
915
+ });
916
+ if (clack.isCancel(whitelistInput)) { clack.outro('Cancelled.'); process.exit(0); }
917
+
918
+ const whitelist = whitelistInput
919
+ ? whitelistInput.split(',').map(n => n.trim()).filter(Boolean)
920
+ : [];
921
+
922
+ // Step 4: Save config
923
+ let existingConfig = {};
924
+ try {
925
+ if (configExists()) existingConfig = loadConfig();
926
+ } catch { /* ignore */ }
927
+
928
+ const config = {
929
+ ...existingConfig,
930
+ signal: {
931
+ signal_service: signalService,
932
+ phone_number: phoneNumber,
933
+ whitelist,
934
+ session_timeout: 3600,
935
+ },
936
+ };
937
+
938
+ const configPath = writeConfig(config);
939
+
940
+ clack.log.success('Signal configuration saved.');
941
+ clack.outro(`Run cipher bot to start. Config: ${configPath}`);
942
+ return {};
943
+ }
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // Update command — self-update from npm
947
+ // ---------------------------------------------------------------------------
948
+
949
+ export async function handleUpdate(args = {}) {
950
+ const { execSync } = await import('node:child_process');
951
+ const { readFileSync } = await import('node:fs');
952
+ const { join, dirname } = await import('node:path');
953
+ const brand = await import('../brand.js');
954
+
955
+ const pkgPath = join(dirname(new URL(import.meta.url).pathname), '..', '..', 'package.json');
956
+ let currentVersion = 'unknown';
957
+ try {
958
+ currentVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
959
+ } catch { /* ignore */ }
960
+
961
+ brand.header('Update', currentVersion);
962
+ brand.info(`Current version: ${currentVersion}`);
963
+ brand.info('Checking npm for updates...');
964
+
965
+ try {
966
+ const latest = execSync('npm view cipher-security version', {
967
+ encoding: 'utf-8',
968
+ timeout: 10000,
969
+ }).trim();
970
+
971
+ if (latest === currentVersion) {
972
+ brand.success(`Already up to date (${currentVersion})`);
973
+ return {};
974
+ }
975
+
976
+ brand.warn(`New version available: ${latest}`);
977
+ brand.info('Installing update...');
978
+
979
+ execSync('npm install -g cipher-security@latest', {
980
+ encoding: 'utf-8',
981
+ timeout: 60000,
982
+ stdio: 'inherit',
983
+ });
984
+
985
+ brand.success(`Updated ${currentVersion} → ${latest}`);
986
+ return {};
987
+ } catch (err) {
988
+ brand.error(`Update failed: ${err.message}`);
989
+ return { error: true, message: `Update failed: ${err.message}` };
990
+ }
991
+ }