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/cli.py ADDED
@@ -0,0 +1,273 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """CIPHER gateway CLI entrypoint — cipher-gw.
4
+
5
+ Usage:
6
+ cipher-gw "how to exploit SQLi"
7
+ echo "what is zero trust" | cipher-gw
8
+ cipher-gw --backend claude "design a zero trust network"
9
+ cipher-gw --smart "analyze this CVE"
10
+ cipher-gw setup-signal [--signal-api http://localhost:8080]
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Smart routing
19
+ # ---------------------------------------------------------------------------
20
+
21
+ COMPLEXITY_KEYWORDS = {
22
+ "design", "architecture", "threat model", "analyze", "analyse",
23
+ "compare", "explain", "model", "dpia", "risk assessment",
24
+ "implement", "system", "strategy",
25
+ }
26
+
27
+
28
+ def _select_backend_smart(message: str) -> str:
29
+ """Select backend based on query complexity heuristic.
30
+
31
+ Routes to "claude" if the message is long (> 200 chars) or contains
32
+ a complexity keyword. Routes to "ollama" otherwise.
33
+
34
+ Args:
35
+ message: The user query string.
36
+
37
+ Returns:
38
+ "claude" for complex queries, "ollama" for simple lookups.
39
+ """
40
+ if len(message) > 200:
41
+ return "claude"
42
+ lower = message.lower()
43
+ if any(keyword in lower for keyword in COMPLEXITY_KEYWORDS):
44
+ return "claude"
45
+ return "ollama"
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def _strip_captcha_token(raw: str) -> str:
53
+ """Strip the 'signalcaptcha://' scheme prefix if present.
54
+
55
+ Signal's captcha page produces URLs like:
56
+ signalcaptcha://signal-hcaptcha.xxx.registration.token
57
+ The API only wants the token portion after the prefix.
58
+ """
59
+ prefix = "signalcaptcha://"
60
+ if raw.startswith(prefix):
61
+ return raw[len(prefix):]
62
+ return raw
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Subcommand handlers
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _setup_signal(args: argparse.Namespace) -> None:
70
+ """Interactive Signal registration flow using signal-cli-rest-api REST API."""
71
+ import httpx # deferred import — only needed for this subcommand
72
+
73
+ base = args.signal_api.rstrip("/")
74
+
75
+ print("CIPHER Signal Bot Registration")
76
+ print("=" * 40)
77
+ print("Prerequisites: signal-cli-rest-api must be running")
78
+ print(" docker compose up -d signal-api")
79
+ print()
80
+
81
+ # Step 1: verify signal-cli-rest-api is reachable
82
+ with httpx.Client(timeout=10) as client:
83
+ try:
84
+ about_url = f"{base}/v1/about"
85
+ client.get(about_url).raise_for_status()
86
+ print("signal-cli-rest-api is reachable.")
87
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
88
+ print(f"Error: Could not connect to signal-cli-rest-api at {base}")
89
+ print(f" {exc}")
90
+ print("Make sure the container is running: docker compose up -d signal-api")
91
+ sys.exit(1)
92
+ except httpx.HTTPStatusError as exc:
93
+ print(f"Error: signal-cli-rest-api returned an error: {exc}")
94
+ sys.exit(1)
95
+
96
+ # Step 2: prompt for number + captcha, then register
97
+ number = input("Phone number (E.164 format, e.g. +15551234567): ").strip()
98
+ print()
99
+ print("Captcha steps:")
100
+ print(" 1. Open https://signalcaptchas.org/registration/generate.html")
101
+ print(" 2. Solve the captcha")
102
+ print(" 3. Right-click 'Open Signal' link -> Copy link address")
103
+ print(" 4. Paste the full URL below")
104
+ raw_captcha = input("Captcha URL or token: ").strip()
105
+ captcha_token = _strip_captcha_token(raw_captcha)
106
+
107
+ try:
108
+ register_url = f"{base}/v1/register/{number}"
109
+ client.post(
110
+ register_url,
111
+ json={"captcha": captcha_token, "use_voice": False},
112
+ ).raise_for_status()
113
+ except httpx.HTTPStatusError as exc:
114
+ print(f"Error: Registration request failed: {exc}")
115
+ print("Hint: The captcha must be solved from the same IP as signal-cli-rest-api.")
116
+ print(" If using Docker, the captcha page must be opened from the host running the container.")
117
+ sys.exit(1)
118
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
119
+ print(f"Error: Lost connection during registration: {exc}")
120
+ sys.exit(1)
121
+
122
+ # Step 3: verify with the code received by call/SMS
123
+ print("Verification call/SMS initiated. Enter the code when received.")
124
+ code = input("Verification code: ").strip()
125
+
126
+ try:
127
+ verify_url = f"{base}/v1/register/{number}/verify/{code}"
128
+ client.post(verify_url).raise_for_status()
129
+ except httpx.HTTPStatusError as exc:
130
+ print(f"Error: Verification failed: {exc}")
131
+ sys.exit(1)
132
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
133
+ print(f"Error: Lost connection during verification: {exc}")
134
+ sys.exit(1)
135
+
136
+ print(f"Registration complete! Your bot number {number} is ready.")
137
+ print("Next steps:")
138
+ print(f" 1. Set signal.phone_number: {number} in config.yaml")
139
+ print(" 2. Set signal.whitelist: [your-personal-number] in config.yaml")
140
+ print(" 3. Run: docker compose up cipher-bot")
141
+
142
+
143
+ def _ingest(args: argparse.Namespace) -> None:
144
+ """Ingest knowledge/ docs into the RAG vector store."""
145
+ from gateway.retriever import Retriever
146
+
147
+ print("CIPHER RAG Ingestion")
148
+ print("=" * 40)
149
+
150
+ retriever = Retriever()
151
+ count = retriever.ingest()
152
+
153
+ print(f"Indexed {count} chunks from knowledge/ into ChromaDB.")
154
+ print("RAG retrieval is now active for cipher-gw queries.")
155
+
156
+
157
+ def _send_message(args: argparse.Namespace) -> None:
158
+ """Send a single message through the Gateway."""
159
+ message: str | None = getattr(args, "message", None)
160
+ if message is None:
161
+ if not sys.stdin.isatty():
162
+ message = sys.stdin.read().strip()
163
+ else:
164
+ # parser.error not accessible here; print and exit manually
165
+ print(
166
+ "cipher-gw: error: No message provided. "
167
+ "Pass a message argument or pipe via stdin.",
168
+ file=sys.stderr,
169
+ )
170
+ sys.exit(2)
171
+
172
+ if not message:
173
+ print("cipher-gw: error: Message is empty.", file=sys.stderr)
174
+ sys.exit(2)
175
+
176
+ from gateway.gateway import Gateway
177
+
178
+ # Smart routing: auto-select backend if --smart is set and no explicit --backend
179
+ if getattr(args, "smart", False) and not getattr(args, "backend", None):
180
+ backend = _select_backend_smart(message)
181
+ print(f"[CIPHER] smart routing -> {backend}", file=sys.stderr)
182
+ args.backend = backend
183
+
184
+ gw = Gateway(backend_override=getattr(args, "backend", None))
185
+ result = gw.send(message)
186
+ print(result)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Argument parser
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def _build_parser() -> argparse.ArgumentParser:
194
+ parser = argparse.ArgumentParser(
195
+ prog="cipher-gw",
196
+ description="CIPHER gateway — send a message or manage Signal registration",
197
+ )
198
+
199
+ subparsers = parser.add_subparsers(dest="subcommand")
200
+
201
+ # ----- send subcommand (default) -----
202
+ send_parser = subparsers.add_parser(
203
+ "send",
204
+ help="Send a message and get a CIPHER-mode response (default)",
205
+ )
206
+ send_parser.add_argument(
207
+ "message",
208
+ nargs="?",
209
+ help="Message to send. Reads from stdin if omitted.",
210
+ )
211
+ send_parser.add_argument(
212
+ "--backend",
213
+ choices=["ollama", "claude"],
214
+ default=None,
215
+ help="Override LLM backend (default: from config.yaml).",
216
+ )
217
+ send_parser.add_argument(
218
+ "--smart",
219
+ action="store_true",
220
+ default=False,
221
+ help="Auto-select backend based on query complexity (logs routing decision).",
222
+ )
223
+ send_parser.set_defaults(func=_send_message)
224
+
225
+ # ----- ingest subcommand -----
226
+ ingest_parser = subparsers.add_parser(
227
+ "ingest",
228
+ help="Ingest knowledge/ docs into the RAG vector store",
229
+ )
230
+ ingest_parser.set_defaults(func=_ingest)
231
+
232
+ # ----- setup-signal subcommand -----
233
+ signal_parser = subparsers.add_parser(
234
+ "setup-signal",
235
+ help="Interactively register a Signal phone number with signal-cli-rest-api",
236
+ )
237
+ signal_parser.add_argument(
238
+ "--signal-api",
239
+ default="http://localhost:8080",
240
+ dest="signal_api",
241
+ help="Base URL for signal-cli-rest-api (default: http://localhost:8080)",
242
+ )
243
+ signal_parser.set_defaults(func=_setup_signal)
244
+
245
+ return parser, send_parser
246
+
247
+
248
+ def main() -> None:
249
+ """CLI entrypoint for cipher-gw."""
250
+ parser, send_parser = _build_parser()
251
+
252
+ # Backward compatibility: detect whether first argument is a known subcommand.
253
+ # If not, prepend "send" so the existing cipher-gw "message" usage keeps working.
254
+ raw_args = sys.argv[1:]
255
+ known_subcommands = {"send", "setup-signal", "ingest"}
256
+ first = raw_args[0] if raw_args else None
257
+ if first not in known_subcommands and first not in ("-h", "--help"):
258
+ # Treat as a send invocation — prepend the "send" subcommand only if
259
+ # the first arg does not start with "--" (which would be a flag for the
260
+ # top-level parser). Flags like --backend are passed through.
261
+ raw_args = ["send"] + raw_args
262
+
263
+ args = parser.parse_args(raw_args)
264
+
265
+ if hasattr(args, "func"):
266
+ args.func(args)
267
+ else:
268
+ parser.print_help()
269
+ sys.exit(1)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()
gateway/client.py ADDED
@@ -0,0 +1,57 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """CIPHER gateway LLM client factory.
4
+
5
+ Returns a configured anthropic.Anthropic client for the active backend.
6
+ Both Ollama and Claude API use the same SDK — Ollama's Anthropic-compat
7
+ layer (v0.14.0+) handles the translation transparently.
8
+
9
+ Usage:
10
+ client, model = make_client(cfg)
11
+ response = client.messages.create(
12
+ model=model,
13
+ max_tokens=4096,
14
+ system=system_prompt,
15
+ messages=[{"role": "user", "content": message}],
16
+ )
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import anthropic
21
+ import httpx
22
+
23
+ from gateway.config import GatewayConfig
24
+
25
+
26
+ def make_client(cfg: GatewayConfig) -> tuple[anthropic.Anthropic, str]:
27
+ """Build an anthropic.Anthropic client for the configured backend.
28
+
29
+ Args:
30
+ cfg: Validated gateway configuration. GatewayConfig.__post_init__
31
+ ensures the backend and active model are set.
32
+
33
+ Returns:
34
+ (client, model_name) — the client is ready to call .messages.create().
35
+
36
+ Notes:
37
+ - Ollama: base_url points to localhost:11434, api_key="ollama" (SDK
38
+ requires a key; Ollama ignores its value).
39
+ - Claude: standard Anthropic API with configured api_key and default
40
+ base_url (api.anthropic.com).
41
+ - Timeouts come from GatewayConfig — 300s for Ollama (local inference
42
+ can be slow), 60s for Claude API.
43
+ """
44
+ if cfg.backend == "ollama":
45
+ client = anthropic.Anthropic(
46
+ base_url=cfg.ollama_base_url,
47
+ api_key="ollama", # Ollama ignores the key; SDK constructor requires it
48
+ timeout=httpx.Timeout(float(cfg.ollama_timeout)),
49
+ )
50
+ return client, cfg.ollama_model
51
+
52
+ # backend == "claude"
53
+ client = anthropic.Anthropic(
54
+ api_key=cfg.claude_api_key,
55
+ timeout=httpx.Timeout(float(cfg.claude_timeout)),
56
+ )
57
+ return client, cfg.claude_model
gateway/config.py ADDED
@@ -0,0 +1,213 @@
1
+ # Copyright (c) 2026 defconxt. All rights reserved.
2
+ # Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ """CIPHER gateway configuration — YAML loading with env var substitution.
4
+
5
+ Precedence (highest to lowest):
6
+ 1. Environment variables (LLM_BACKEND, ANTHROPIC_API_KEY)
7
+ 2. Project-root config.yaml (where pyproject.toml lives)
8
+ 3. ~/.config/cipher/config.yaml
9
+
10
+ Supports ${VAR} and ${VAR:-default} syntax in YAML values.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Environment variable substitution
25
+ # ---------------------------------------------------------------------------
26
+
27
+ # Matches ${VAR} or ${VAR:-default}
28
+ _ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}")
29
+
30
+
31
+ def _substitute_env(value: Any) -> Any:
32
+ """Recursively substitute ${VAR} and ${VAR:-default} in config values."""
33
+ if isinstance(value, str):
34
+ def _replace(m: re.Match) -> str:
35
+ var = m.group(1)
36
+ default = m.group(2)
37
+ env_val = os.environ.get(var)
38
+ if env_val is not None:
39
+ return env_val
40
+ if default is not None:
41
+ return default
42
+ return m.group(0) # Leave unresolved if no default
43
+ return _ENV_RE.sub(_replace, value)
44
+ if isinstance(value, dict):
45
+ return {k: _substitute_env(v) for k, v in value.items()}
46
+ if isinstance(value, list):
47
+ return [_substitute_env(v) for v in value]
48
+ return value
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Secret redaction
53
+ # ---------------------------------------------------------------------------
54
+
55
+ _SECRET_KEYS = {"api_key", "apikey", "api-key", "token", "secret", "password"}
56
+
57
+
58
+ def redact_config(config: dict) -> dict:
59
+ """Return a copy of config with secret values replaced by '***'."""
60
+ def _redact(obj: Any, key: str = "") -> Any:
61
+ if isinstance(obj, dict):
62
+ return {k: _redact(v, k) for k, v in obj.items()}
63
+ if isinstance(obj, list):
64
+ return [_redact(v) for v in obj]
65
+ if isinstance(obj, str) and key.lower().replace("-", "_") in _SECRET_KEYS and obj:
66
+ return "***"
67
+ return obj
68
+ return _redact(config)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Config dataclass
73
+ # ---------------------------------------------------------------------------
74
+
75
+ @dataclass
76
+ class GatewayConfig:
77
+ """Validated, fully-resolved gateway configuration."""
78
+
79
+ backend: str # "ollama" | "claude"
80
+ ollama_base_url: str
81
+ ollama_model: str
82
+ ollama_timeout: int
83
+ claude_api_key: str
84
+ claude_model: str
85
+ claude_timeout: int
86
+
87
+ def __post_init__(self) -> None:
88
+ if self.backend not in ("ollama", "claude"):
89
+ raise ValueError(
90
+ f"Invalid backend {self.backend!r}. Must be 'ollama' or 'claude'.\n"
91
+ "Set 'llm_backend' in config.yaml or run: cipher setup"
92
+ )
93
+ if self.backend == "ollama" and not self.ollama_model:
94
+ raise ValueError(
95
+ "ollama_model must be set when backend is 'ollama'.\n"
96
+ "Set 'ollama.model' in config.yaml or run: cipher setup"
97
+ )
98
+ if self.backend == "claude" and not self.claude_model:
99
+ raise ValueError(
100
+ "claude_model must be set when backend is 'claude'.\n"
101
+ "Set 'claude.model' in config.yaml or run: cipher setup"
102
+ )
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Config loader
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def _find_project_root(start: Path) -> Path | None:
110
+ """Walk up from `start` until pyproject.toml is found. Returns the dir or None."""
111
+ current = start.resolve()
112
+ for _ in range(20):
113
+ if (current / "pyproject.toml").exists():
114
+ return current
115
+ parent = current.parent
116
+ if parent == current:
117
+ break
118
+ current = parent
119
+ return None
120
+
121
+
122
+ def _load_yaml_file(path: Path) -> dict[str, Any]:
123
+ """Load a YAML file safely. Returns empty dict if file missing or empty."""
124
+ if not path.exists():
125
+ return {}
126
+ with path.open("r") as f:
127
+ data = yaml.safe_load(f)
128
+ return data if isinstance(data, dict) else {}
129
+
130
+
131
+ def load_config(project_root: Path | None = None) -> GatewayConfig:
132
+ """Load configuration from YAML files and env var overrides.
133
+
134
+ Supports ${VAR} and ${VAR:-default} syntax in YAML values.
135
+
136
+ Args:
137
+ project_root: Directory to look for project-root config.yaml.
138
+ If None, resolved by walking up from this file's location.
139
+
140
+ Returns:
141
+ Validated GatewayConfig.
142
+
143
+ Raises:
144
+ ValueError: If required fields are missing or invalid.
145
+ """
146
+ if project_root is None:
147
+ found = _find_project_root(Path(__file__).parent)
148
+ project_root = found if found is not None else Path.cwd()
149
+
150
+ home_config = Path.home() / ".config" / "cipher" / "config.yaml"
151
+ project_config = Path(project_root) / "config.yaml"
152
+
153
+ merged: dict[str, Any] = {}
154
+ merged.update(_load_yaml_file(home_config))
155
+ merged.update(_load_yaml_file(project_config))
156
+
157
+ # Substitute ${VAR} patterns in all values
158
+ merged = _substitute_env(merged)
159
+
160
+ # Apply explicit env var overrides (highest priority)
161
+ if backend := os.environ.get("LLM_BACKEND"):
162
+ merged["llm_backend"] = backend
163
+ if api_key := os.environ.get("ANTHROPIC_API_KEY"):
164
+ merged.setdefault("claude", {})
165
+ if not isinstance(merged.get("claude"), dict):
166
+ merged["claude"] = {}
167
+ merged["claude"]["api_key"] = api_key
168
+
169
+ ollama_section: dict[str, Any] = merged.get("ollama") or {}
170
+ claude_section: dict[str, Any] = merged.get("claude") or {}
171
+
172
+ return GatewayConfig(
173
+ backend=merged.get("llm_backend", ""),
174
+ ollama_base_url=ollama_section.get("base_url", "http://127.0.0.1:11434"),
175
+ ollama_model=ollama_section.get("model", ""),
176
+ ollama_timeout=int(ollama_section.get("timeout", 300)),
177
+ claude_api_key=claude_section.get("api_key", ""),
178
+ claude_model=claude_section.get("model", ""),
179
+ claude_timeout=int(claude_section.get("timeout", 60)),
180
+ )
181
+
182
+
183
+ def load_signal_config(project_root: Path | None = None) -> dict[str, Any]:
184
+ """Load the 'signal:' section from config.yaml with env var overrides."""
185
+ if project_root is None:
186
+ found = _find_project_root(Path(__file__).parent)
187
+ project_root = found if found is not None else Path.cwd()
188
+
189
+ home_config = Path.home() / ".config" / "cipher" / "config.yaml"
190
+ project_config = Path(project_root) / "config.yaml"
191
+
192
+ merged: dict[str, Any] = {}
193
+ merged.update(_load_yaml_file(home_config))
194
+ merged.update(_load_yaml_file(project_config))
195
+ merged = _substitute_env(merged)
196
+
197
+ signal_section: dict[str, Any] = merged.get("signal") or {}
198
+
199
+ result: dict[str, Any] = {
200
+ "signal_service": signal_section.get("signal_service", "signal-api:8080"),
201
+ "phone_number": signal_section.get("phone_number", ""),
202
+ "whitelist": signal_section.get("whitelist") or [],
203
+ "session_timeout": int(signal_section.get("session_timeout", 3600)),
204
+ }
205
+
206
+ if sv := os.environ.get("SIGNAL_SERVICE"):
207
+ result["signal_service"] = sv
208
+ if spn := os.environ.get("SIGNAL_PHONE_NUMBER"):
209
+ result["phone_number"] = spn
210
+ if swl := os.environ.get("SIGNAL_WHITELIST"):
211
+ result["whitelist"] = [n.strip() for n in swl.split(",") if n.strip()]
212
+
213
+ return result