cipher-security 0.2.0__py3-none-any.whl

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.
gateway/app.py ADDED
@@ -0,0 +1,607 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """CIPHER CLI — Typer-based command interface.
4
+
5
+ Usage:
6
+ cipher "how to detect Kerberoasting"
7
+ cipher --backend ollama "reverse shell techniques"
8
+ cipher --smart "design a zero trust architecture"
9
+ cipher setup
10
+ cipher doctor
11
+ cipher status
12
+ cipher ingest
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from enum import Enum
18
+ from typing import Optional
19
+
20
+ import typer
21
+ from rich.markdown import Markdown
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+
25
+ from gateway.theme import (
26
+ MODE_COLORS,
27
+ console,
28
+ err_console,
29
+ print_banner,
30
+ print_error,
31
+ print_info,
32
+ print_success,
33
+ print_warn,
34
+ )
35
+
36
+ app = typer.Typer(
37
+ name="cipher",
38
+ help="CIPHER — Security Engineering Assistant",
39
+ no_args_is_help=True,
40
+ rich_markup_mode="rich",
41
+ add_completion=True,
42
+ )
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Enums
47
+ # ---------------------------------------------------------------------------
48
+
49
+ class Backend(str, Enum):
50
+ ollama = "ollama"
51
+ claude = "claude"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Smart routing (preserved from original CLI)
56
+ # ---------------------------------------------------------------------------
57
+
58
+ COMPLEXITY_KEYWORDS = {
59
+ "design", "architecture", "threat model", "analyze", "analyse",
60
+ "compare", "explain", "model", "dpia", "risk assessment",
61
+ "implement", "system", "strategy",
62
+ }
63
+
64
+
65
+ def _select_backend_smart(message: str) -> str:
66
+ if len(message) > 200:
67
+ return "claude"
68
+ lower = message.lower()
69
+ if any(kw in lower for kw in COMPLEXITY_KEYWORDS):
70
+ return "claude"
71
+ return "ollama"
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # cipher query (default command)
76
+ # ---------------------------------------------------------------------------
77
+
78
+ @app.command()
79
+ def query(
80
+ message: Optional[str] = typer.Argument(None, help="Security query to send to CIPHER."),
81
+ backend: Optional[Backend] = typer.Option(None, "--backend", "-b", help="LLM backend override."),
82
+ smart: bool = typer.Option(False, "--smart", "-s", help="Auto-route: simple→Ollama, complex→Claude."),
83
+ ) -> None:
84
+ """Send a security query through the CIPHER pipeline."""
85
+ if message is None:
86
+ if not sys.stdin.isatty():
87
+ message = sys.stdin.read().strip()
88
+ else:
89
+ console.print("[cipher.error]No query provided.[/] Pass a message or pipe via stdin.")
90
+ raise typer.Exit(2)
91
+
92
+ if not message:
93
+ console.print("[cipher.error]Query is empty.[/]")
94
+ raise typer.Exit(2)
95
+
96
+ from gateway.gateway import Gateway
97
+
98
+ # Smart routing
99
+ backend_str = None
100
+ if smart and backend is None:
101
+ backend_str = _select_backend_smart(message)
102
+ err_console.print(f"[cipher.muted]smart routing → {backend_str}[/]")
103
+ elif backend is not None:
104
+ backend_str = backend.value
105
+
106
+ # Send with spinner
107
+ with console.status("[cipher.primary]Thinking...", spinner="dots"):
108
+ gw = Gateway(backend_override=backend_str)
109
+ result = gw.send(message)
110
+
111
+ # Colorize mode header
112
+ _print_response(result)
113
+
114
+
115
+ def _print_response(text: str) -> None:
116
+ """Print a CIPHER response with colorized mode header."""
117
+ lines = text.split("\n", 1)
118
+ header = lines[0]
119
+ body = lines[1] if len(lines) > 1 else ""
120
+
121
+ # Extract mode from header
122
+ mode = None
123
+ if header.startswith("[MODE:"):
124
+ mode = header.split(":")[1].strip().rstrip("]").strip()
125
+
126
+ if mode and mode in MODE_COLORS:
127
+ color = MODE_COLORS[mode]
128
+ console.print(f"[{color}]{header}[/]")
129
+ else:
130
+ console.print(header)
131
+
132
+ if body.strip():
133
+ console.print(Markdown(body))
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # cipher ingest
138
+ # ---------------------------------------------------------------------------
139
+
140
+ @app.command()
141
+ def ingest() -> None:
142
+ """Re-index the knowledge base into the RAG vector store."""
143
+ from gateway.retriever import Retriever
144
+
145
+ print_banner()
146
+ console.print("[cipher.primary]RAG Ingestion[/]\n")
147
+
148
+ with console.status("[cipher.primary]Indexing knowledge base...", spinner="dots"):
149
+ retriever = Retriever()
150
+ count = retriever.ingest()
151
+
152
+ print_success(f"Indexed {count} chunks into ChromaDB")
153
+ print_info("RAG retrieval is now active for queries")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # cipher dashboard
158
+ # ---------------------------------------------------------------------------
159
+
160
+ @app.command()
161
+ def dashboard() -> None:
162
+ """Launch the interactive cyberpunk terminal dashboard."""
163
+ from gateway.dashboard import run_dashboard
164
+ run_dashboard()
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # cipher setup
169
+ # ---------------------------------------------------------------------------
170
+
171
+ @app.command()
172
+ def setup(
173
+ backend: Optional[Backend] = typer.Option(None, "--backend", "-b", help="Pre-select backend (skip prompt)."),
174
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="Claude API key (skip prompt)."),
175
+ non_interactive: bool = typer.Option(False, "--non-interactive", help="No prompts, use flags + env vars."),
176
+ ) -> None:
177
+ """Interactive onboarding wizard — configure CIPHER on first run."""
178
+ import shutil
179
+ import subprocess
180
+
181
+ import yaml
182
+ from rich.prompt import Confirm, Prompt
183
+
184
+ from gateway.prompt import REPO_ROOT
185
+
186
+ print_banner()
187
+ console.print("[cipher.primary]Setup Wizard[/]\n")
188
+
189
+ config: dict = {}
190
+
191
+ # Step 1: Choose backend
192
+ if backend is not None:
193
+ chosen_backend = backend.value
194
+ elif non_interactive:
195
+ chosen_backend = "ollama"
196
+ else:
197
+ console.print(" [cipher.primary]Choose your LLM backend:[/]\n")
198
+ console.print(" [bold]1[/] [cipher.success]Ollama[/] — Local inference, free, private (requires GPU)")
199
+ console.print(" [bold]2[/] [cipher.accent]Claude API[/] — Cloud inference, paid, highest quality\n")
200
+ choice = Prompt.ask(" Select", choices=["1", "2"], default="1")
201
+ chosen_backend = "ollama" if choice == "1" else "claude"
202
+
203
+ config["llm_backend"] = chosen_backend
204
+ print_success(f"Backend: {chosen_backend}")
205
+ console.print()
206
+
207
+ # Step 2: Backend-specific configuration
208
+ if chosen_backend == "ollama":
209
+ # Check if Ollama is running
210
+ ollama_url = "http://127.0.0.1:11434"
211
+ config["ollama"] = {"base_url": ollama_url, "model": "cipher", "timeout": 300}
212
+
213
+ if shutil.which("ollama"):
214
+ try:
215
+ r = subprocess.run(["ollama", "list"], capture_output=True, timeout=5)
216
+ if r.returncode == 0:
217
+ print_success("Ollama is running")
218
+ output = r.stdout.decode()
219
+ if "cipher" not in output:
220
+ console.print()
221
+ if non_interactive or Confirm.ask(" [cipher.warning]cipher model not found.[/] Create it now?", default=True):
222
+ modelfile = REPO_ROOT / "Modelfile"
223
+ if modelfile.exists():
224
+ with console.status("[cipher.primary]Creating cipher model (this may take a minute)...", spinner="dots"):
225
+ subprocess.run(
226
+ ["ollama", "create", "cipher", "-f", str(modelfile)],
227
+ capture_output=True, timeout=300,
228
+ )
229
+ print_success("cipher model created (qwen2.5:32b)")
230
+ else:
231
+ print_warn("Modelfile not found — create manually: ollama create cipher -f Modelfile")
232
+ else:
233
+ print_success("cipher model loaded")
234
+ else:
235
+ print_warn("Ollama installed but not responding — start with: ollama serve")
236
+ except (subprocess.TimeoutExpired, FileNotFoundError):
237
+ print_warn("Ollama not responding — start with: ollama serve")
238
+ else:
239
+ print_warn("Ollama not installed — see https://ollama.com/download")
240
+
241
+ # Set default Claude config (empty, for when user switches later)
242
+ config["claude"] = {"api_key": "", "model": "claude-sonnet-4-5-20250929", "timeout": 120}
243
+
244
+ else: # claude
245
+ config["ollama"] = {"base_url": "http://127.0.0.1:11434", "model": "cipher", "timeout": 300}
246
+
247
+ # Get API key
248
+ if api_key:
249
+ key = api_key
250
+ elif non_interactive:
251
+ import os
252
+ key = os.environ.get("ANTHROPIC_API_KEY", "")
253
+ if not key:
254
+ print_error("--non-interactive requires ANTHROPIC_API_KEY env var or --api-key")
255
+ raise typer.Exit(1)
256
+ else:
257
+ key = Prompt.ask(" [cipher.accent]Anthropic API key[/]", password=True)
258
+
259
+ if key:
260
+ # Validate key
261
+ with console.status("[cipher.primary]Validating API key...", spinner="dots"):
262
+ try:
263
+ import anthropic
264
+ client = anthropic.Anthropic(api_key=key)
265
+ client.messages.create(
266
+ model="claude-sonnet-4-5-20250929",
267
+ max_tokens=10,
268
+ messages=[{"role": "user", "content": "ping"}],
269
+ )
270
+ print_success("API key validated")
271
+ except Exception as exc:
272
+ print_error(f"API key validation failed: {exc}")
273
+ if not non_interactive and not Confirm.ask(" Continue anyway?", default=False):
274
+ raise typer.Exit(1)
275
+
276
+ config["claude"] = {"api_key": key, "model": "claude-sonnet-4-5-20250929", "timeout": 120}
277
+
278
+ # Step 3: Write config
279
+ console.print()
280
+ config_path = REPO_ROOT / "config.yaml"
281
+ with open(config_path, "w") as f:
282
+ yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)
283
+ print_success(f"Config written to {config_path}")
284
+
285
+ # Step 4: Ingest knowledge base
286
+ console.print()
287
+ console.print(" [cipher.primary]Indexing knowledge base...[/]")
288
+ try:
289
+ with console.status("[cipher.primary]Building RAG index...", spinner="dots"):
290
+ from gateway.retriever import Retriever
291
+ retriever = Retriever()
292
+ count = retriever.ingest()
293
+ print_success(f"Indexed {count:,} chunks from knowledge/")
294
+ except Exception as exc:
295
+ print_warn(f"RAG indexing failed: {exc}")
296
+ print_info("Run 'cipher ingest' later to retry")
297
+
298
+ # Step 5: Demo query
299
+ console.print()
300
+ if not non_interactive and Confirm.ask(" [cipher.primary]Run a demo query?[/]", default=True):
301
+ demo_query = "What are the top 3 techniques for detecting lateral movement?"
302
+ console.print(f"\n [cipher.muted]> {demo_query}[/]\n")
303
+ try:
304
+ with console.status("[cipher.primary]Thinking...", spinner="dots"):
305
+ from gateway.gateway import Gateway
306
+ gw = Gateway(backend_override=chosen_backend)
307
+ result = gw.send(demo_query)
308
+ _print_response(result)
309
+ except Exception as exc:
310
+ print_warn(f"Demo query failed: {exc}")
311
+ print_info("This is normal if the backend isn't fully configured yet")
312
+
313
+ # Done
314
+ console.print()
315
+ console.print(Panel(
316
+ "[cipher.success]Setup complete![/]\n\n"
317
+ 'Try: [bold]cipher "how do I detect Kerberoasting"[/]\n'
318
+ "Run: [bold]cipher doctor[/] to verify everything\n"
319
+ "Run: [bold]cipher status[/] to see system info",
320
+ title="[cipher.primary]Ready[/]",
321
+ border_style="cipher.primary",
322
+ ))
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # cipher status
327
+ # ---------------------------------------------------------------------------
328
+
329
+ @app.command()
330
+ def status() -> None:
331
+ """Show CIPHER system status."""
332
+ from gateway.config import load_config
333
+ from gateway.prompt import REPO_ROOT
334
+
335
+ print_banner()
336
+
337
+ cfg = load_config()
338
+
339
+ # Backend
340
+ table = Table(show_header=False, box=None, padding=(0, 2))
341
+ table.add_column("Key", style="cipher.muted")
342
+ table.add_column("Value")
343
+
344
+ table.add_row("Backend", f"[bold]{cfg.backend}[/]")
345
+
346
+ if cfg.backend == "ollama":
347
+ table.add_row("Model", cfg.ollama_model)
348
+ table.add_row("Ollama URL", cfg.ollama_base_url)
349
+ else:
350
+ table.add_row("Model", cfg.claude_model)
351
+
352
+ # RAG
353
+ try:
354
+ from gateway.retriever import Retriever
355
+ r = Retriever()
356
+ table.add_row("RAG Chunks", f"[cipher.success]{r.count:,}[/]")
357
+ except Exception:
358
+ table.add_row("RAG Chunks", "[cipher.warning]unavailable[/]")
359
+
360
+ # Knowledge
361
+ kb_dir = REPO_ROOT / "knowledge"
362
+ if kb_dir.is_dir():
363
+ md_count = len(list(kb_dir.glob("*.md")))
364
+ table.add_row("Knowledge Docs", str(md_count))
365
+
366
+ # Skills
367
+ skills_dir = REPO_ROOT / "skills"
368
+ if skills_dir.is_dir():
369
+ skill_count = len(list(skills_dir.rglob("SKILL.md")))
370
+ table.add_row("Skill Files", str(skill_count))
371
+
372
+ # Modes
373
+ table.add_row("Modes", "RED, BLUE, PURPLE, PRIVACY, RECON, INCIDENT, ARCHITECT")
374
+
375
+ console.print(Panel(table, title="[cipher.primary]CIPHER Status[/]", border_style="cipher.primary"))
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # cipher doctor
380
+ # ---------------------------------------------------------------------------
381
+
382
+ @app.command()
383
+ def doctor(
384
+ fix: bool = typer.Option(False, "--fix", help="Auto-repair issues where possible."),
385
+ ) -> None:
386
+ """Run diagnostics and health checks."""
387
+ import shutil
388
+ import subprocess
389
+
390
+ from gateway.prompt import REPO_ROOT
391
+
392
+ print_banner()
393
+ console.print("[cipher.primary]Diagnostics[/]\n")
394
+
395
+ issues = 0
396
+
397
+ # 1. Python version
398
+ v = sys.version_info
399
+ if v >= (3, 10):
400
+ print_success(f"Python {v.major}.{v.minor}.{v.micro}")
401
+ else:
402
+ print_error(f"Python {v.major}.{v.minor} — need 3.10+")
403
+ issues += 1
404
+
405
+ # 2. Ollama running
406
+ if shutil.which("ollama"):
407
+ try:
408
+ r = subprocess.run(["ollama", "list"], capture_output=True, timeout=5)
409
+ if r.returncode == 0:
410
+ output = r.stdout.decode()
411
+ if "cipher" in output:
412
+ print_success("Ollama running, cipher model loaded")
413
+ else:
414
+ print_warn("Ollama running but cipher model not found")
415
+ if fix:
416
+ print_info("Creating cipher model...")
417
+ modelfile = REPO_ROOT / "Modelfile"
418
+ if modelfile.exists():
419
+ subprocess.run(["ollama", "create", "cipher", "-f", str(modelfile)], timeout=300)
420
+ print_success("cipher model created")
421
+ else:
422
+ print_info("Run: ollama create cipher -f Modelfile")
423
+ issues += 1
424
+ else:
425
+ print_error("Ollama not responding")
426
+ issues += 1
427
+ except (subprocess.TimeoutExpired, FileNotFoundError):
428
+ print_error("Ollama not responding")
429
+ issues += 1
430
+ else:
431
+ print_warn("Ollama not installed (optional for local inference)")
432
+
433
+ # 3. Claude API key
434
+ import os
435
+ if os.environ.get("ANTHROPIC_API_KEY"):
436
+ print_success("Claude API key configured (env)")
437
+ else:
438
+ try:
439
+ from gateway.config import load_config
440
+ cfg = load_config()
441
+ if cfg.claude_api_key:
442
+ print_success("Claude API key configured (config)")
443
+ else:
444
+ print_warn("No Claude API key (optional if using Ollama)")
445
+ except Exception:
446
+ print_warn("No Claude API key configured")
447
+
448
+ # 4. RAG index
449
+ try:
450
+ from gateway.retriever import Retriever
451
+ r = Retriever()
452
+ if r.count > 0:
453
+ print_success(f"RAG index: {r.count:,} chunks")
454
+ else:
455
+ print_error("RAG index empty")
456
+ if fix:
457
+ print_info("Ingesting knowledge base...")
458
+ count = r.ingest()
459
+ print_success(f"Ingested {count:,} chunks")
460
+ else:
461
+ print_info("Run: cipher ingest")
462
+ issues += 1
463
+ except Exception as exc:
464
+ print_error(f"RAG unavailable: {exc}")
465
+ issues += 1
466
+
467
+ # 5. Knowledge directory
468
+ kb_dir = REPO_ROOT / "knowledge"
469
+ if kb_dir.is_dir():
470
+ md_files = list(kb_dir.glob("*.md"))
471
+ empty = [f for f in md_files if f.stat().st_size < 100]
472
+ if empty:
473
+ print_warn(f"Knowledge: {len(md_files)} docs, {len(empty)} empty/tiny")
474
+ for f in empty:
475
+ print_info(f" {f.name}")
476
+ issues += 1
477
+ else:
478
+ print_success(f"Knowledge: {len(md_files)} docs, all have content")
479
+ else:
480
+ print_error("Knowledge directory missing")
481
+ issues += 1
482
+
483
+ # 6. Skill files
484
+ skills_dir = REPO_ROOT / "skills"
485
+ expected_skills = [
486
+ "red-team", "blue-team", "purple-team", "privacy-engineering",
487
+ "osint-recon", "incident-response", "threat-intelligence",
488
+ "automation-scripting", "security-architecture",
489
+ ]
490
+ missing_skills = [s for s in expected_skills if not (skills_dir / s / "SKILL.md").exists()]
491
+ if missing_skills:
492
+ print_error(f"Missing skill files: {', '.join(missing_skills)}")
493
+ issues += 1
494
+ else:
495
+ print_success(f"All {len(expected_skills)} skill files present")
496
+
497
+ # Summary
498
+ console.print()
499
+ if issues == 0:
500
+ console.print("[cipher.success]All checks passed.[/]")
501
+ else:
502
+ console.print(f"[cipher.warning]{issues} issue(s) found.[/]")
503
+ if not fix:
504
+ console.print("[cipher.muted]Run with --fix to auto-repair.[/]")
505
+
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # cipher setup-signal (preserved from original)
509
+ # ---------------------------------------------------------------------------
510
+
511
+ @app.command("setup-signal")
512
+ def setup_signal(
513
+ signal_api: str = typer.Option("http://localhost:8080", help="signal-cli-rest-api URL."),
514
+ ) -> None:
515
+ """Register a Signal phone number for the CIPHER bot."""
516
+ import httpx
517
+
518
+ print_banner()
519
+ console.print("[cipher.primary]Signal Bot Registration[/]\n")
520
+ print_info("Prerequisites: docker compose up -d signal-api\n")
521
+
522
+ with httpx.Client(timeout=10) as client:
523
+ # Verify API
524
+ try:
525
+ client.get(f"{signal_api.rstrip('/')}/v1/about").raise_for_status()
526
+ print_success("signal-cli-rest-api is reachable")
527
+ except Exception as exc:
528
+ print_error(f"Cannot reach signal-cli-rest-api: {exc}")
529
+ raise typer.Exit(1)
530
+
531
+ # Register
532
+ number = typer.prompt("Phone number (E.164)")
533
+ console.print("\n[cipher.muted]Captcha steps:[/]")
534
+ print_info("1. Open https://signalcaptchas.org/registration/generate.html")
535
+ print_info("2. Solve the captcha")
536
+ print_info("3. Right-click 'Open Signal' → Copy link address")
537
+ raw = typer.prompt("Captcha URL or token")
538
+ token = raw.removeprefix("signalcaptcha://")
539
+
540
+ try:
541
+ client.post(
542
+ f"{signal_api.rstrip('/')}/v1/register/{number}",
543
+ json={"captcha": token, "use_voice": False},
544
+ ).raise_for_status()
545
+ except httpx.HTTPStatusError as exc:
546
+ print_error(f"Registration failed: {exc}")
547
+ print_info("Captcha must be solved from same IP as signal-cli-rest-api")
548
+ raise typer.Exit(1)
549
+
550
+ code = typer.prompt("Verification code")
551
+ try:
552
+ client.post(f"{signal_api.rstrip('/')}/v1/register/{number}/verify/{code}").raise_for_status()
553
+ except httpx.HTTPStatusError as exc:
554
+ print_error(f"Verification failed: {exc}")
555
+ raise typer.Exit(1)
556
+
557
+ print_success(f"Registered {number}")
558
+ print_info(f"Set signal.phone_number: {number} in config.yaml")
559
+ print_info("Run: docker compose up cipher-bot")
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # cipher version
564
+ # ---------------------------------------------------------------------------
565
+
566
+ @app.command()
567
+ def version() -> None:
568
+ """Show CIPHER version."""
569
+ console.print("[cipher.primary]CIPHER[/] v0.2.0")
570
+
571
+
572
+ # ---------------------------------------------------------------------------
573
+ # Default: bare `cipher "query"` without subcommand
574
+ # ---------------------------------------------------------------------------
575
+
576
+ def main() -> None:
577
+ """CLI entrypoint — handles bare `cipher "message"` by prepending `query`.
578
+
579
+ Supports:
580
+ cipher "how do I detect Kerberoasting" → cipher query "..."
581
+ cipher --backend ollama "query" → cipher query --backend ollama "..."
582
+ cipher -s "complex analysis" → cipher query -s "..."
583
+ cipher status → runs status subcommand
584
+ """
585
+ args = sys.argv[1:]
586
+ known_commands = {"query", "ingest", "status", "doctor", "setup", "setup-signal", "dashboard", "version"}
587
+ global_flags = {"--help", "-h", "--install-completion", "--show-completion"}
588
+
589
+ # Find the first non-flag argument
590
+ first_positional = None
591
+ for a in args:
592
+ if not a.startswith("-"):
593
+ first_positional = a
594
+ break
595
+
596
+ # If the first positional isn't a known command, prepend "query"
597
+ if first_positional is not None and first_positional not in known_commands:
598
+ args = ["query"] + args
599
+ elif not args or (args and args[0] in global_flags):
600
+ pass # --help or no args — let Typer handle it
601
+
602
+ sys.argv = [sys.argv[0]] + args
603
+ app()
604
+
605
+
606
+ if __name__ == "__main__":
607
+ main()