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/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
|