cipher-security 5.2.0 → 5.3.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/bin/cipher.js CHANGED
@@ -466,13 +466,29 @@ if (mode === 'native') {
466
466
  mcp.startStdio();
467
467
  // ── Bot command: Signal bot ──────────────────────────────────────────
468
468
  } else if (command === 'bot') {
469
- const { banner, header, info, warn, divider } = await import('../lib/brand.js');
470
- const svc = commandArgs.find((a, i) => commandArgs[i - 1] === '--service') || process.env.SIGNAL_SERVICE || '';
471
- const phone = commandArgs.find((a, i) => commandArgs[i - 1] === '--phone') || process.env.SIGNAL_PHONE_NUMBER || '';
469
+ const { banner, header, info, warn, success, divider } = await import('../lib/brand.js');
470
+ const { loadConfig, configExists } = await import('../lib/config.js');
472
471
 
473
472
  banner(pkg.version);
474
473
  header('Signal Bot');
475
474
 
475
+ // Load signal config
476
+ let signalCfg = {};
477
+ if (configExists()) {
478
+ try {
479
+ const cfg = loadConfig();
480
+ signalCfg = cfg.signal || {};
481
+ } catch { /* ignore */ }
482
+ }
483
+
484
+ const svc = commandArgs.find((a, i) => commandArgs[i - 1] === '--service')
485
+ || process.env.SIGNAL_SERVICE || signalCfg.signal_service || '';
486
+ const phone = commandArgs.find((a, i) => commandArgs[i - 1] === '--phone')
487
+ || process.env.SIGNAL_PHONE_NUMBER || signalCfg.phone_number || '';
488
+ const whitelist = process.env.SIGNAL_WHITELIST
489
+ ? process.env.SIGNAL_WHITELIST.split(',').map(n => n.trim())
490
+ : signalCfg.whitelist || [];
491
+
476
492
  if (!svc || !phone) {
477
493
  divider();
478
494
  warn('Signal is not configured.');
@@ -481,13 +497,19 @@ if (mode === 'native') {
481
497
  process.exit(1);
482
498
  }
483
499
 
484
- info(`Service: ${svc}`);
485
- info(`Phone: ${phone}`);
486
500
  divider();
487
- info('Starting bot...');
501
+ success(`Service: ${svc}`);
502
+ success(`Phone: ${phone}`);
503
+ if (whitelist.length) {
504
+ success(`Whitelist: ${whitelist.join(', ')}`);
505
+ } else {
506
+ warn('Whitelist: none (all senders allowed)');
507
+ }
508
+ divider();
509
+ info('Starting bot... (Ctrl+C to stop)');
488
510
 
489
511
  const { runBot } = await import('../lib/bot/bot.js');
490
- runBot({ signalService: svc, phoneNumber: phone });
512
+ await runBot({ signalService: svc, phoneNumber: phone, whitelist, sessionTimeout: signalCfg.session_timeout || 3600 });
491
513
  // ── Query command: streaming via Gateway or non-streaming via handler ──
492
514
  } else if (command === 'query') {
493
515
  const queryText = commandArgs.filter(a => !a.startsWith('-')).join(' ');
package/lib/bot/bot.js CHANGED
@@ -2,20 +2,18 @@
2
2
  // Licensed under AGPL-3.0 — see LICENSE file for details.
3
3
 
4
4
  /**
5
- * CIPHER Signal Bot — format utilities and session manager.
5
+ * CIPHER Signal Bot — receives messages via signal-cli-rest-api WebSocket,
6
+ * dispatches through the gateway, and replies.
6
7
  */
7
8
 
9
+ import { WebSocket } from 'ws';
10
+
8
11
  // ---------------------------------------------------------------------------
9
12
  // Prefix mapping
10
13
  // ---------------------------------------------------------------------------
11
14
 
12
15
  const PREFIX_RE = /^(RED|BLUE|PURPLE|PRIVACY|RECON|INCIDENT|ARCHITECT):\s*/i;
13
16
 
14
- /**
15
- * Map 'MODE: query' short-prefix format to '[MODE: MODE] query'.
16
- * @param {string} text
17
- * @returns {string}
18
- */
19
17
  export function mapPrefix(text) {
20
18
  const m = text.match(PREFIX_RE);
21
19
  if (m) {
@@ -31,20 +29,15 @@ export function mapPrefix(text) {
31
29
  // ---------------------------------------------------------------------------
32
30
 
33
31
  const MD_PATTERNS = [
34
- [/```[^\n]*\n([\s\S]*?)```/g, '$1'], // Fenced code blocks
35
- [/^#{1,6}\s+/gm, ''], // Headers
36
- [/\*{2}([^*\n]+)\*{2}/g, '$1'], // Bold
37
- [/\*([^*\n]+)\*/g, '$1'], // Italic
38
- [/`([^`\n]+)`/g, '$1'], // Inline code
39
- [/\[([^\]]+)\]\([^)]+\)/g, '$1'], // Links
40
- [/^(\s*)[*-]\s+/gm, '$1• '], // Bullets
32
+ [/```[^\n]*\n([\s\S]*?)```/g, '$1'],
33
+ [/^#{1,6}\s+/gm, ''],
34
+ [/\*{2}([^*\n]+)\*{2}/g, '$1'],
35
+ [/\*([^*\n]+)\*/g, '$1'],
36
+ [/`([^`\n]+)`/g, '$1'],
37
+ [/\[([^\]]+)\]\([^)]+\)/g, '$1'],
38
+ [/^(\s*)[*-]\s+/gm, '$1• '],
41
39
  ];
42
40
 
43
- /**
44
- * Strip markdown formatting for plaintext Signal delivery.
45
- * @param {string} text
46
- * @returns {string}
47
- */
48
41
  export function stripMarkdown(text) {
49
42
  for (const [pattern, replacement] of MD_PATTERNS) {
50
43
  text = text.replace(pattern, replacement);
@@ -56,15 +49,7 @@ export function stripMarkdown(text) {
56
49
  // Session manager
57
50
  // ---------------------------------------------------------------------------
58
51
 
59
- /**
60
- * In-memory conversation history manager keyed by sender.
61
- */
62
52
  export class SessionManager {
63
- /**
64
- * @param {object} [opts]
65
- * @param {number} [opts.timeoutSeconds=3600]
66
- * @param {number} [opts.maxPairs=20]
67
- */
68
53
  constructor({ timeoutSeconds = 3600, maxPairs = 20 } = {}) {
69
54
  this._sessions = new Map();
70
55
  this._timeout = timeoutSeconds * 1000;
@@ -92,9 +77,7 @@ export class SessionManager {
92
77
  }
93
78
  }
94
79
 
95
- reset(sender) {
96
- this._sessions.delete(sender);
97
- }
80
+ reset(sender) { this._sessions.delete(sender); }
98
81
 
99
82
  cleanup() {
100
83
  const now = Date.now();
@@ -109,22 +92,147 @@ export class SessionManager {
109
92
  }
110
93
 
111
94
  // ---------------------------------------------------------------------------
112
- // Bot runner (placeholder — requires signal-cli subprocess)
95
+ // Signal REST API client
96
+ // ---------------------------------------------------------------------------
97
+
98
+ async function sendMessage(signalService, phoneNumber, recipient, message) {
99
+ const resp = await fetch(`${signalService}/v2/send`, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ message,
104
+ number: phoneNumber,
105
+ recipients: [recipient],
106
+ }),
107
+ });
108
+ if (!resp.ok) {
109
+ throw new Error(`Send failed: ${resp.status} ${await resp.text()}`);
110
+ }
111
+ }
112
+
113
+ async function sendReaction(signalService, phoneNumber, recipient, emoji, targetTimestamp) {
114
+ try {
115
+ await fetch(`${signalService}/v1/reactions/${phoneNumber}`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({
119
+ reaction: emoji,
120
+ recipient,
121
+ timestamp: targetTimestamp,
122
+ }),
123
+ });
124
+ } catch { /* best-effort */ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Bot runner
113
129
  // ---------------------------------------------------------------------------
114
130
 
115
131
  /**
116
132
  * Start the CIPHER Signal bot.
117
133
  *
118
- * In the full implementation, this would:
119
- * 1. Load config from config.yaml
120
- * 2. Start signal-cli subprocess for receiving messages
121
- * 3. Register CipherCommand handler
122
- * 4. Run with exponential-backoff watchdog
134
+ * Connects to signal-cli-rest-api WebSocket, listens for messages,
135
+ * dispatches through the gateway, and replies.
123
136
  *
124
137
  * @param {object} config
138
+ * @param {string} config.signalService - signal-cli-rest-api URL
139
+ * @param {string} config.phoneNumber - bot's phone number
140
+ * @param {string[]} [config.whitelist] - allowed sender numbers
141
+ * @param {number} [config.sessionTimeout] - session timeout in seconds
125
142
  */
126
- export function runBot(config) {
127
- console.log(`CIPHER Bot starting (service: ${config.signalService}, phone: ${config.phoneNumber})`);
128
- console.log('Bot requires signal-cli see docs for setup.');
129
- // The actual bot would use signal-cli subprocess here
143
+ export async function runBot(config) {
144
+ const { signalService, phoneNumber, whitelist = [], sessionTimeout = 3600 } = config;
145
+ const sessions = new SessionManager({ timeoutSeconds: sessionTimeout });
146
+ const whitelistSet = new Set(whitelist);
147
+
148
+ const wsUrl = `${signalService.replace('http', 'ws')}/v1/receive/${phoneNumber}`;
149
+
150
+ let reconnectDelay = 1000;
151
+ const maxDelay = 60000;
152
+
153
+ function connect() {
154
+ const ws = new WebSocket(wsUrl);
155
+
156
+ ws.on('open', () => {
157
+ process.stderr.write(` ● Connected to ${wsUrl}\n`);
158
+ reconnectDelay = 1000;
159
+ });
160
+
161
+ ws.on('message', async (data) => {
162
+ try {
163
+ const envelope = JSON.parse(data.toString());
164
+ await handleEnvelope(envelope, config, sessions, whitelistSet);
165
+ } catch (err) {
166
+ process.stderr.write(` ✖ Message error: ${err.message}\n`);
167
+ }
168
+ });
169
+
170
+ ws.on('close', () => {
171
+ process.stderr.write(` ⚠ WebSocket closed. Reconnecting in ${reconnectDelay / 1000}s...\n`);
172
+ setTimeout(connect, reconnectDelay);
173
+ reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
174
+ });
175
+
176
+ ws.on('error', (err) => {
177
+ process.stderr.write(` ✖ WebSocket error: ${err.message}\n`);
178
+ });
179
+ }
180
+
181
+ connect();
182
+ }
183
+
184
+ async function handleEnvelope(envelope, config, sessions, whitelistSet) {
185
+ const msg = envelope.envelope?.dataMessage;
186
+ if (!msg || !msg.message) return;
187
+
188
+ const sender = envelope.envelope?.source;
189
+ if (!sender) return;
190
+
191
+ const text = msg.message.trim();
192
+ if (!text) return;
193
+
194
+ // Whitelist check
195
+ if (whitelistSet.size > 0 && !whitelistSet.has(sender)) {
196
+ return;
197
+ }
198
+
199
+ const timestamp = msg.timestamp;
200
+
201
+ // /reset command
202
+ if (text === '/reset') {
203
+ sessions.reset(sender);
204
+ await sendMessage(config.signalService, config.phoneNumber, sender, 'Session cleared.');
205
+ return;
206
+ }
207
+
208
+ // React with brain emoji to acknowledge
209
+ await sendReaction(config.signalService, config.phoneNumber, sender, '🧠', timestamp);
210
+
211
+ // Map prefix and get history
212
+ const mapped = mapPrefix(text);
213
+ const history = sessions.get(sender);
214
+
215
+ // Dispatch through gateway
216
+ let response;
217
+ try {
218
+ const { Gateway } = await import('../gateway/gateway.js');
219
+ const gw = new Gateway({ rag: true });
220
+ response = await gw.send(mapped, { history });
221
+ } catch (err) {
222
+ response = `Error: ${err.message}`;
223
+ }
224
+
225
+ // Update session and reply
226
+ sessions.update(sender, mapped, response);
227
+ const reply = stripMarkdown(response);
228
+
229
+ // Signal has a ~6000 char limit per message
230
+ if (reply.length > 5500) {
231
+ const chunks = reply.match(/.{1,5500}/gs) || [reply];
232
+ for (const chunk of chunks) {
233
+ await sendMessage(config.signalService, config.phoneNumber, sender, chunk);
234
+ }
235
+ } else {
236
+ await sendMessage(config.signalService, config.phoneNumber, sender, reply);
237
+ }
130
238
  }
@@ -821,17 +821,124 @@ export async function handleWeb(args = {}) {
821
821
  // ---------------------------------------------------------------------------
822
822
 
823
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');
824
827
  const brand = await import('../brand.js');
825
- brand.header('Signal Setup');
826
- brand.divider();
827
- brand.info('Set the following environment variables:');
828
- process.stderr.write('\n');
829
- process.stderr.write(' SIGNAL_SERVICE Signal API service URL\n');
830
- process.stderr.write(' SIGNAL_PHONE_NUMBER Bot phone number (E.164 format)\n');
831
- process.stderr.write(' SIGNAL_WHITELIST Comma-separated allowed numbers\n');
832
- process.stderr.write('\n');
833
- brand.info('Then run: cipher bot');
834
- brand.divider();
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}`);
835
942
  return {};
836
943
  }
837
944
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cipher-security",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "CIPHER — AI Security Engineering Platform CLI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,6 +25,7 @@
25
25
  "@clack/prompts": "^1.1.0",
26
26
  "better-sqlite3": "^12.8.0",
27
27
  "openai": "^6.32.0",
28
+ "ws": "^8.19.0",
28
29
  "yaml": "^2.8.2"
29
30
  }
30
31
  }