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.
- bot/__init__.py +1 -0
- bot/bot.py +234 -0
- bot/format.py +84 -0
- bot/session.py +98 -0
- cipher_security-0.2.0.dist-info/METADATA +249 -0
- cipher_security-0.2.0.dist-info/RECORD +20 -0
- cipher_security-0.2.0.dist-info/WHEEL +4 -0
- cipher_security-0.2.0.dist-info/entry_points.txt +4 -0
- cipher_security-0.2.0.dist-info/licenses/LICENSE +661 -0
- gateway/__init__.py +8 -0
- gateway/app.py +607 -0
- gateway/cli.py +273 -0
- gateway/client.py +57 -0
- gateway/config.py +213 -0
- gateway/dashboard.py +347 -0
- gateway/gateway.py +171 -0
- gateway/mode.py +127 -0
- gateway/prompt.py +165 -0
- gateway/retriever.py +257 -0
- gateway/theme.py +91 -0
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()
|