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/dashboard.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
# Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
"""CIPHER Dashboard — cyberpunk terminal UI built with Textual.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
cipher dashboard
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from textual import on, work
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.css.query import NoMatches
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Footer,
|
|
17
|
+
Header,
|
|
18
|
+
Input,
|
|
19
|
+
Label,
|
|
20
|
+
Markdown,
|
|
21
|
+
Static,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Cyberpunk CSS
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
CIPHER_CSS = """
|
|
30
|
+
Screen {
|
|
31
|
+
background: #0a0a0a;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Header {
|
|
35
|
+
background: #0d1117;
|
|
36
|
+
color: #00ff41;
|
|
37
|
+
text-style: bold;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Footer {
|
|
41
|
+
background: #0d1117;
|
|
42
|
+
color: #5a6480;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#status-panel {
|
|
46
|
+
height: 7;
|
|
47
|
+
background: #0d1117;
|
|
48
|
+
border: solid #1a1f2e;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.status-key {
|
|
53
|
+
color: #5a6480;
|
|
54
|
+
width: 14;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.status-val {
|
|
58
|
+
color: #c9d1d9;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.status-val-good {
|
|
62
|
+
color: #00e5cc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#mode-bar {
|
|
66
|
+
height: 3;
|
|
67
|
+
background: #0d1117;
|
|
68
|
+
border: solid #1a1f2e;
|
|
69
|
+
padding: 0 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.mode-label {
|
|
73
|
+
width: 12;
|
|
74
|
+
content-align: center middle;
|
|
75
|
+
text-style: bold;
|
|
76
|
+
margin: 0 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.mode-red { background: #2d0a0a; color: #ff2d2d; }
|
|
80
|
+
.mode-blue { background: #0a1a2d; color: #2d9bff; }
|
|
81
|
+
.mode-purple { background: #1a0a2d; color: #9b59b6; }
|
|
82
|
+
.mode-privacy { background: #0a1a0d; color: #27ae60; }
|
|
83
|
+
.mode-recon { background: #1a1a0a; color: #f39c12; }
|
|
84
|
+
.mode-incident { background: #2d0a0a; color: #e74c3c; }
|
|
85
|
+
.mode-architect { background: #0a1a2d; color: #3498db; }
|
|
86
|
+
|
|
87
|
+
#response-area {
|
|
88
|
+
background: #0d1117;
|
|
89
|
+
border: solid #1a1f2e;
|
|
90
|
+
padding: 1 2;
|
|
91
|
+
overflow-y: auto;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#response-area Markdown {
|
|
95
|
+
background: #0d1117;
|
|
96
|
+
color: #c9d1d9;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#query-input {
|
|
100
|
+
dock: bottom;
|
|
101
|
+
background: #161b22;
|
|
102
|
+
color: #00ff41;
|
|
103
|
+
border: tall #00ff41;
|
|
104
|
+
padding: 0 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#query-input:focus {
|
|
108
|
+
border: tall #00ff41;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#welcome {
|
|
112
|
+
color: #5a6480;
|
|
113
|
+
text-align: center;
|
|
114
|
+
padding: 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.spinner-label {
|
|
118
|
+
color: #00ff41;
|
|
119
|
+
text-style: italic;
|
|
120
|
+
}
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Dashboard App
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
class CipherDashboard(App):
|
|
129
|
+
"""CIPHER cyberpunk terminal dashboard."""
|
|
130
|
+
|
|
131
|
+
TITLE = "CIPHER"
|
|
132
|
+
SUB_TITLE = "Security Engineering Assistant"
|
|
133
|
+
CSS = CIPHER_CSS
|
|
134
|
+
|
|
135
|
+
BINDINGS = [
|
|
136
|
+
Binding("ctrl+q", "quit", "Quit"),
|
|
137
|
+
Binding("ctrl+l", "clear", "Clear"),
|
|
138
|
+
Binding("escape", "focus_input", "Focus Input", show=False),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
super().__init__()
|
|
143
|
+
self._gateway = None
|
|
144
|
+
self._history: list[dict] = []
|
|
145
|
+
|
|
146
|
+
def compose(self) -> ComposeResult:
|
|
147
|
+
yield Header()
|
|
148
|
+
|
|
149
|
+
# Status panel
|
|
150
|
+
with Horizontal(id="status-panel"):
|
|
151
|
+
with Vertical():
|
|
152
|
+
yield Label("[b]Backend[/]", classes="status-key")
|
|
153
|
+
yield Label("[b]Model[/]", classes="status-key")
|
|
154
|
+
yield Label("[b]RAG[/]", classes="status-key")
|
|
155
|
+
with Vertical():
|
|
156
|
+
yield Label("loading...", id="val-backend", classes="status-val")
|
|
157
|
+
yield Label("loading...", id="val-model", classes="status-val")
|
|
158
|
+
yield Label("loading...", id="val-rag", classes="status-val")
|
|
159
|
+
with Vertical():
|
|
160
|
+
yield Label("[b]Knowledge[/]", classes="status-key")
|
|
161
|
+
yield Label("[b]Skills[/]", classes="status-key")
|
|
162
|
+
yield Label("[b]Modes[/]", classes="status-key")
|
|
163
|
+
with Vertical():
|
|
164
|
+
yield Label("loading...", id="val-knowledge", classes="status-val")
|
|
165
|
+
yield Label("loading...", id="val-skills", classes="status-val")
|
|
166
|
+
yield Label("7", id="val-modes", classes="status-val-good")
|
|
167
|
+
|
|
168
|
+
# Mode bar
|
|
169
|
+
with Horizontal(id="mode-bar"):
|
|
170
|
+
yield Label("RED", classes="mode-label mode-red")
|
|
171
|
+
yield Label("BLUE", classes="mode-label mode-blue")
|
|
172
|
+
yield Label("PURPLE", classes="mode-label mode-purple")
|
|
173
|
+
yield Label("PRIVACY", classes="mode-label mode-privacy")
|
|
174
|
+
yield Label("RECON", classes="mode-label mode-recon")
|
|
175
|
+
yield Label("INCIDENT", classes="mode-label mode-incident")
|
|
176
|
+
yield Label("ARCHITECT", classes="mode-label mode-architect")
|
|
177
|
+
|
|
178
|
+
# Response area
|
|
179
|
+
with VerticalScroll(id="response-area"):
|
|
180
|
+
yield Static(
|
|
181
|
+
"[dim]Type a security query below and press Enter.\n\n"
|
|
182
|
+
"Examples:\n"
|
|
183
|
+
' "how do I detect Kerberoasting"\n'
|
|
184
|
+
' "write a Sigma rule for lateral movement via PsExec"\n'
|
|
185
|
+
' "GDPR breach notification requirements"\n'
|
|
186
|
+
' "[MODE: RED] exploit AS-REP roasting"\n\n'
|
|
187
|
+
"Ctrl+L to clear, Ctrl+Q to quit.[/]",
|
|
188
|
+
id="welcome",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Query input
|
|
192
|
+
yield Input(placeholder="Enter security query...", id="query-input")
|
|
193
|
+
|
|
194
|
+
yield Footer()
|
|
195
|
+
|
|
196
|
+
def on_mount(self) -> None:
|
|
197
|
+
"""Load status on startup."""
|
|
198
|
+
self._load_status()
|
|
199
|
+
self.query_one("#query-input", Input).focus()
|
|
200
|
+
|
|
201
|
+
@work(thread=True)
|
|
202
|
+
def _load_status(self) -> None:
|
|
203
|
+
"""Load system status in background thread."""
|
|
204
|
+
try:
|
|
205
|
+
from gateway.config import load_config
|
|
206
|
+
cfg = load_config()
|
|
207
|
+
self.call_from_thread(self._update_label, "val-backend", cfg.backend, good=True)
|
|
208
|
+
model = cfg.ollama_model if cfg.backend == "ollama" else cfg.claude_model
|
|
209
|
+
self.call_from_thread(self._update_label, "val-model", model, good=True)
|
|
210
|
+
except Exception:
|
|
211
|
+
self.call_from_thread(self._update_label, "val-backend", "not configured", good=False)
|
|
212
|
+
self.call_from_thread(self._update_label, "val-model", "—", good=False)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
from gateway.retriever import Retriever
|
|
216
|
+
r = Retriever()
|
|
217
|
+
self.call_from_thread(self._update_label, "val-rag", f"{r.count:,} chunks", good=True)
|
|
218
|
+
except Exception:
|
|
219
|
+
self.call_from_thread(self._update_label, "val-rag", "unavailable", good=False)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
from gateway.prompt import REPO_ROOT
|
|
223
|
+
kb = REPO_ROOT / "knowledge"
|
|
224
|
+
count = len(list(kb.glob("*.md"))) if kb.is_dir() else 0
|
|
225
|
+
self.call_from_thread(self._update_label, "val-knowledge", f"{count} docs", good=True)
|
|
226
|
+
skills = REPO_ROOT / "skills"
|
|
227
|
+
scount = len(list(skills.rglob("SKILL.md"))) if skills.is_dir() else 0
|
|
228
|
+
self.call_from_thread(self._update_label, "val-skills", str(scount), good=True)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def _update_label(self, label_id: str, text: str, good: bool = True) -> None:
|
|
233
|
+
try:
|
|
234
|
+
label = self.query_one(f"#{label_id}", Label)
|
|
235
|
+
cls = "status-val-good" if good else "status-val"
|
|
236
|
+
label.update(text)
|
|
237
|
+
label.set_classes(cls)
|
|
238
|
+
except NoMatches:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
@on(Input.Submitted, "#query-input")
|
|
242
|
+
def on_query_submit(self, event: Input.Submitted) -> None:
|
|
243
|
+
"""Handle query submission."""
|
|
244
|
+
query = event.value.strip()
|
|
245
|
+
if not query:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
event.input.value = ""
|
|
249
|
+
self._send_query(query)
|
|
250
|
+
|
|
251
|
+
@work(thread=True)
|
|
252
|
+
def _send_query(self, query: str) -> None:
|
|
253
|
+
"""Send query to gateway in background thread."""
|
|
254
|
+
# Show user query
|
|
255
|
+
self.call_from_thread(self._append_message, "user", query)
|
|
256
|
+
self.call_from_thread(self._append_message, "system", "Thinking...")
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
if self._gateway is None:
|
|
260
|
+
from gateway.gateway import Gateway
|
|
261
|
+
self._gateway = Gateway()
|
|
262
|
+
|
|
263
|
+
result = self._gateway.send(query, history=self._history if self._history else None)
|
|
264
|
+
|
|
265
|
+
# Update history
|
|
266
|
+
self._history.append({"role": "user", "content": query})
|
|
267
|
+
self._history.append({"role": "assistant", "content": result})
|
|
268
|
+
|
|
269
|
+
# Remove "Thinking..." and show response
|
|
270
|
+
self.call_from_thread(self._remove_thinking)
|
|
271
|
+
self.call_from_thread(self._append_response, result)
|
|
272
|
+
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
self.call_from_thread(self._remove_thinking)
|
|
275
|
+
self.call_from_thread(self._append_message, "error", f"Error: {exc}")
|
|
276
|
+
|
|
277
|
+
def _append_message(self, role: str, text: str) -> None:
|
|
278
|
+
container = self.query_one("#response-area", VerticalScroll)
|
|
279
|
+
# Remove welcome on first message
|
|
280
|
+
try:
|
|
281
|
+
welcome = self.query_one("#welcome", Static)
|
|
282
|
+
welcome.remove()
|
|
283
|
+
except NoMatches:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
if role == "user":
|
|
287
|
+
container.mount(Static(f"[bold #00ff41]> {text}[/]"))
|
|
288
|
+
elif role == "system":
|
|
289
|
+
container.mount(Static(f"[italic #5a6480]{text}[/]", classes="spinner-label"))
|
|
290
|
+
elif role == "error":
|
|
291
|
+
container.mount(Static(f"[bold #ff4d4d]{text}[/]"))
|
|
292
|
+
|
|
293
|
+
container.scroll_end()
|
|
294
|
+
|
|
295
|
+
def _remove_thinking(self) -> None:
|
|
296
|
+
try:
|
|
297
|
+
for widget in self.query(".spinner-label"):
|
|
298
|
+
widget.remove()
|
|
299
|
+
except NoMatches:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
def _append_response(self, text: str) -> None:
|
|
303
|
+
container = self.query_one("#response-area", VerticalScroll)
|
|
304
|
+
|
|
305
|
+
# Colorize mode header
|
|
306
|
+
lines = text.split("\n", 1)
|
|
307
|
+
header = lines[0]
|
|
308
|
+
body = lines[1] if len(lines) > 1 else ""
|
|
309
|
+
|
|
310
|
+
mode_colors = {
|
|
311
|
+
"RED": "#ff2d2d", "BLUE": "#2d9bff", "PURPLE": "#9b59b6",
|
|
312
|
+
"PRIVACY": "#27ae60", "RECON": "#f39c12", "INCIDENT": "#e74c3c",
|
|
313
|
+
"ARCHITECT": "#3498db",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if header.startswith("[MODE:"):
|
|
317
|
+
mode = header.split(":")[1].strip().rstrip("]").strip()
|
|
318
|
+
color = mode_colors.get(mode, "#00ff41")
|
|
319
|
+
container.mount(Static(f"[bold {color}]{header}[/]"))
|
|
320
|
+
else:
|
|
321
|
+
body = text
|
|
322
|
+
|
|
323
|
+
if body.strip():
|
|
324
|
+
container.mount(Markdown(body))
|
|
325
|
+
|
|
326
|
+
container.mount(Static("[dim]─────────────────────────────────────────[/]"))
|
|
327
|
+
container.scroll_end()
|
|
328
|
+
|
|
329
|
+
def action_clear(self) -> None:
|
|
330
|
+
"""Clear response area and history."""
|
|
331
|
+
container = self.query_one("#response-area", VerticalScroll)
|
|
332
|
+
container.remove_children()
|
|
333
|
+
container.mount(Static("[dim]Cleared. Type a new query.[/]"))
|
|
334
|
+
self._history.clear()
|
|
335
|
+
|
|
336
|
+
def action_focus_input(self) -> None:
|
|
337
|
+
self.query_one("#query-input", Input).focus()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def run_dashboard() -> None:
|
|
341
|
+
"""Launch the CIPHER dashboard."""
|
|
342
|
+
app = CipherDashboard()
|
|
343
|
+
app.run()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
run_dashboard()
|
gateway/gateway.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
# Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
"""CIPHER Gateway orchestrator.
|
|
4
|
+
|
|
5
|
+
Wires config, client, prompt, mode, and retriever modules into a single send() interface.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from gateway import Gateway
|
|
9
|
+
|
|
10
|
+
gw = Gateway()
|
|
11
|
+
response = gw.send("how do I exploit a SQLi vulnerability")
|
|
12
|
+
print(response) # [MODE: RED] ...
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
import anthropic
|
|
19
|
+
|
|
20
|
+
from gateway.client import make_client
|
|
21
|
+
from gateway.config import load_config
|
|
22
|
+
from gateway.mode import detect_mode, parse_keyword_table, strip_mode_prefix
|
|
23
|
+
from gateway.prompt import assemble_system_prompt
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_retriever():
|
|
29
|
+
"""Lazy-load the RAG retriever. Returns None if unavailable."""
|
|
30
|
+
try:
|
|
31
|
+
from gateway.retriever import Retriever
|
|
32
|
+
r = Retriever()
|
|
33
|
+
if r.count > 0:
|
|
34
|
+
return r
|
|
35
|
+
logger.info("Gateway: RAG collection empty — running without retrieval")
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
logger.info("Gateway: RAG unavailable (%s) — running without retrieval", exc)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Gateway:
|
|
42
|
+
"""CIPHER gateway orchestrator.
|
|
43
|
+
|
|
44
|
+
Initialises once from config and routes every message through the
|
|
45
|
+
full pipeline: mode detection → prompt assembly → RAG retrieval → LLM call → response.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, backend_override: str | None = None, rag: bool = True) -> None:
|
|
49
|
+
"""Initialise gateway.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
backend_override: If provided, replaces config.backend.
|
|
53
|
+
Useful for testing or CLI --backend flag.
|
|
54
|
+
rag: Whether to enable RAG retrieval. Defaults to True.
|
|
55
|
+
"""
|
|
56
|
+
cfg = load_config()
|
|
57
|
+
if backend_override is not None:
|
|
58
|
+
# Replace backend in-place — GatewayConfig is a mutable dataclass.
|
|
59
|
+
cfg.backend = backend_override
|
|
60
|
+
logger.info("Gateway: backend overridden to %r", backend_override)
|
|
61
|
+
|
|
62
|
+
self._client, self._model = make_client(cfg)
|
|
63
|
+
logger.info("Gateway: client ready (backend=%r, model=%r)", cfg.backend, self._model)
|
|
64
|
+
|
|
65
|
+
# Cache keyword table at startup so every send() call is O(1) lookup.
|
|
66
|
+
# Parse from raw CLAUDE.md (not the stripped base prompt) since
|
|
67
|
+
# load_base_prompt() removes the Trigger Keywords section.
|
|
68
|
+
from gateway.prompt import REPO_ROOT
|
|
69
|
+
raw_claude_md = (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
|
|
70
|
+
self._keyword_table: dict[str, list[str]] = parse_keyword_table(raw_claude_md)
|
|
71
|
+
logger.info("Gateway: keyword table loaded (%d modes)", len(self._keyword_table))
|
|
72
|
+
|
|
73
|
+
# RAG retriever (lazy, graceful fallback if chromadb not installed or empty).
|
|
74
|
+
self._retriever = _load_retriever() if rag else None
|
|
75
|
+
if self._retriever:
|
|
76
|
+
logger.info("Gateway: RAG enabled (%d chunks)", self._retriever.count)
|
|
77
|
+
|
|
78
|
+
def send(self, message: str, history: list[dict] | None = None) -> str:
|
|
79
|
+
"""Send a message through the full CIPHER pipeline.
|
|
80
|
+
|
|
81
|
+
Pipeline:
|
|
82
|
+
1. Detect mode (explicit prefix or keyword scoring).
|
|
83
|
+
2. If needs_clarification → return disambiguation string (no LLM call).
|
|
84
|
+
3. Assemble system prompt for detected mode.
|
|
85
|
+
4. Strip mode prefix from message.
|
|
86
|
+
5. Build messages list (history + new user message).
|
|
87
|
+
6. Call LLM.
|
|
88
|
+
7. Prepend [MODE: X] header if missing.
|
|
89
|
+
8. Return response.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
message: User message text. May include [MODE: X] prefix.
|
|
93
|
+
history: Optional list of prior message dicts
|
|
94
|
+
({"role": "user"|"assistant", "content": "..."}).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Response string, always starting with [MODE: X] header.
|
|
98
|
+
"""
|
|
99
|
+
# 1. Detect mode.
|
|
100
|
+
mode, needs_clarification = detect_mode(message, self._keyword_table)
|
|
101
|
+
|
|
102
|
+
# 2. Disambiguation short-circuit.
|
|
103
|
+
if needs_clarification:
|
|
104
|
+
logger.info("Gateway: mode overlap detected (%r) — requesting clarification", mode)
|
|
105
|
+
return (
|
|
106
|
+
f"Multiple modes detected ({mode}). "
|
|
107
|
+
"Are you approaching this offensively or defensively?"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
logger.info("Gateway: routing to mode %r", mode)
|
|
111
|
+
|
|
112
|
+
# 3. Assemble system prompt.
|
|
113
|
+
system_prompt = assemble_system_prompt(mode)
|
|
114
|
+
|
|
115
|
+
# 3b. RAG retrieval — inject relevant knowledge chunks.
|
|
116
|
+
if self._retriever:
|
|
117
|
+
chunks = self._retriever.query(message, top_k=5)
|
|
118
|
+
if chunks:
|
|
119
|
+
from gateway.retriever import Retriever
|
|
120
|
+
context_block = Retriever.format_context(chunks)
|
|
121
|
+
if context_block:
|
|
122
|
+
system_prompt = system_prompt + "\n\n---\n\n" + context_block
|
|
123
|
+
logger.info(
|
|
124
|
+
"Gateway: injected %d RAG chunks (%.1f KB)",
|
|
125
|
+
len(chunks),
|
|
126
|
+
len(context_block) / 1024,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# 4. Strip mode prefix.
|
|
130
|
+
clean_message = strip_mode_prefix(message)
|
|
131
|
+
|
|
132
|
+
# 5. Prepend [MODE: X] for LLM context.
|
|
133
|
+
user_content = f"[MODE: {mode}] {clean_message}"
|
|
134
|
+
|
|
135
|
+
# 6. Build messages list.
|
|
136
|
+
messages: list[dict] = []
|
|
137
|
+
if history:
|
|
138
|
+
messages.extend(history)
|
|
139
|
+
messages.append({"role": "user", "content": user_content})
|
|
140
|
+
|
|
141
|
+
# 7. Call LLM.
|
|
142
|
+
try:
|
|
143
|
+
response = self._client.messages.create(
|
|
144
|
+
model=self._model,
|
|
145
|
+
max_tokens=4096,
|
|
146
|
+
system=system_prompt,
|
|
147
|
+
messages=messages,
|
|
148
|
+
)
|
|
149
|
+
response_text: str = response.content[0].text
|
|
150
|
+
|
|
151
|
+
except anthropic.APITimeoutError:
|
|
152
|
+
logger.error("Gateway: LLM backend timed out")
|
|
153
|
+
return "[ERROR] Backend timed out. Is the LLM backend running?"
|
|
154
|
+
|
|
155
|
+
except anthropic.APIConnectionError as exc:
|
|
156
|
+
logger.error("Gateway: cannot reach backend — %s", exc)
|
|
157
|
+
return f"[ERROR] Cannot reach backend: {exc}"
|
|
158
|
+
|
|
159
|
+
except anthropic.APIStatusError as exc:
|
|
160
|
+
logger.error("Gateway: backend returned HTTP %s — %s", exc.status_code, exc.message)
|
|
161
|
+
return f"[ERROR] Backend returned {exc.status_code}: {exc.message}"
|
|
162
|
+
|
|
163
|
+
# 8. Ensure exactly one [MODE: X] header is present.
|
|
164
|
+
if not response_text.startswith("[MODE:"):
|
|
165
|
+
response_text = f"[MODE: {mode}]\n{response_text}"
|
|
166
|
+
# Strip duplicate header if LLM echoed it twice.
|
|
167
|
+
lines = response_text.split("\n", 2)
|
|
168
|
+
if len(lines) >= 2 and lines[0].startswith("[MODE:") and lines[1].startswith("[MODE:"):
|
|
169
|
+
response_text = "\n".join([lines[0]] + lines[2:]) if len(lines) > 2 else lines[0]
|
|
170
|
+
|
|
171
|
+
return response_text
|
gateway/mode.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
# Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
"""Mode detection for CIPHER gateway.
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- parse_keyword_table: extract mode→keywords from CLAUDE.md trigger table
|
|
7
|
+
- detect_mode: route message to operating mode via explicit prefix or keyword scoring
|
|
8
|
+
- strip_mode_prefix: remove [MODE: X] prefix from message text
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Matches [MODE: X] at the start of a message, case-insensitive.
|
|
17
|
+
_EXPLICIT_RE = re.compile(r"^\[MODE:\s*(\w+)\]", re.IGNORECASE)
|
|
18
|
+
|
|
19
|
+
# Matches the trigger keywords table block inside CLAUDE.md.
|
|
20
|
+
# Looks for a header containing "Trigger Keywords", then captures rows.
|
|
21
|
+
_TABLE_HEADER_RE = re.compile(
|
|
22
|
+
r"Trigger Keywords.*?\n\|[-| ]+\|\n(.*?)(?:\n[-]+|\n\n|\Z)",
|
|
23
|
+
re.DOTALL | re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
# Individual data row: | WORD | content |
|
|
26
|
+
# Skips header rows where the first cell is "Mode" (case-insensitive).
|
|
27
|
+
_TABLE_ROW_RE = re.compile(r"^\|\s*(\w+)\s*\|\s*(.+?)\s*\|", re.MULTILINE)
|
|
28
|
+
# Known header/separator cell values to skip.
|
|
29
|
+
_SKIP_ROW_NAMES = {"MODE", "---"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_keyword_table(claude_md_text: str) -> dict[str, list[str]]:
|
|
33
|
+
"""Parse the ``| Mode | Trigger Keywords |`` table from CLAUDE.md text.
|
|
34
|
+
|
|
35
|
+
Returns a dict mapping UPPERCASE mode names to lists of lowercase,
|
|
36
|
+
stripped keyword strings. Returns empty dict if the table is absent or
|
|
37
|
+
malformed.
|
|
38
|
+
"""
|
|
39
|
+
if not claude_md_text.strip():
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
# Find the trigger keywords section — everything after the separator row.
|
|
43
|
+
header_match = _TABLE_HEADER_RE.search(claude_md_text)
|
|
44
|
+
if not header_match:
|
|
45
|
+
# Try a looser parse: find any table-like block with Mode | Trigger columns.
|
|
46
|
+
# Scan line by line for rows after a header that contains "Trigger Keywords".
|
|
47
|
+
if "Trigger Keywords" not in claude_md_text:
|
|
48
|
+
logger.warning("parse_keyword_table: no 'Trigger Keywords' table found")
|
|
49
|
+
return {}
|
|
50
|
+
# Fall through to row-level scan across the full text.
|
|
51
|
+
rows_text = claude_md_text
|
|
52
|
+
else:
|
|
53
|
+
rows_text = header_match.group(0)
|
|
54
|
+
|
|
55
|
+
result: dict[str, list[str]] = {}
|
|
56
|
+
for row_match in _TABLE_ROW_RE.finditer(rows_text):
|
|
57
|
+
mode = row_match.group(1).strip().upper()
|
|
58
|
+
# Skip table header row (| Mode | Trigger Keywords |) and separators.
|
|
59
|
+
if mode in _SKIP_ROW_NAMES:
|
|
60
|
+
continue
|
|
61
|
+
raw_keywords = row_match.group(2)
|
|
62
|
+
# Skip if the "keyword" cell looks like a column header.
|
|
63
|
+
if "trigger keywords" in raw_keywords.lower():
|
|
64
|
+
continue
|
|
65
|
+
keywords = [kw.strip().lower() for kw in raw_keywords.split(",") if kw.strip()]
|
|
66
|
+
if mode and keywords:
|
|
67
|
+
result[mode] = keywords
|
|
68
|
+
|
|
69
|
+
if not result:
|
|
70
|
+
logger.warning("parse_keyword_table: table parsed to empty dict")
|
|
71
|
+
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect_mode(
|
|
76
|
+
message: str,
|
|
77
|
+
keyword_table: dict[str, list[str]],
|
|
78
|
+
) -> tuple[str, bool]:
|
|
79
|
+
"""Detect the CIPHER operating mode for a given message.
|
|
80
|
+
|
|
81
|
+
Detection order:
|
|
82
|
+
1. Explicit ``[MODE: X]`` prefix — returns immediately, no clarification needed.
|
|
83
|
+
2. Keyword scoring — scan lowercased message against keyword_table.
|
|
84
|
+
- Single mode match → return (mode, False)
|
|
85
|
+
- Multiple mode matches → return (comma-joined sorted modes, True)
|
|
86
|
+
- No match → return ("ARCHITECT", False)
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
message: Raw user message text (may include [MODE: X] prefix).
|
|
90
|
+
keyword_table: Dict of mode→keywords from parse_keyword_table.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Tuple of (mode_string, needs_clarification).
|
|
94
|
+
"""
|
|
95
|
+
# 1. Check explicit prefix.
|
|
96
|
+
prefix_match = _EXPLICIT_RE.match(message)
|
|
97
|
+
if prefix_match:
|
|
98
|
+
return prefix_match.group(1).upper(), False
|
|
99
|
+
|
|
100
|
+
# 2. Keyword scoring.
|
|
101
|
+
lower_message = message.lower()
|
|
102
|
+
matched_modes: set[str] = set()
|
|
103
|
+
|
|
104
|
+
for mode, keywords in keyword_table.items():
|
|
105
|
+
for keyword in keywords:
|
|
106
|
+
if keyword in lower_message:
|
|
107
|
+
matched_modes.add(mode)
|
|
108
|
+
break # One hit per mode is enough.
|
|
109
|
+
|
|
110
|
+
if len(matched_modes) == 0:
|
|
111
|
+
return "ARCHITECT", False
|
|
112
|
+
if len(matched_modes) == 1:
|
|
113
|
+
return matched_modes.pop(), False
|
|
114
|
+
|
|
115
|
+
# Multiple matches — needs clarification.
|
|
116
|
+
sorted_modes = ",".join(sorted(matched_modes))
|
|
117
|
+
return sorted_modes, True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def strip_mode_prefix(message: str) -> str:
|
|
121
|
+
"""Remove ``[MODE: X]`` prefix from message text.
|
|
122
|
+
|
|
123
|
+
Strips any leading whitespace after the prefix. Returns the original
|
|
124
|
+
string unchanged if no prefix is present.
|
|
125
|
+
"""
|
|
126
|
+
result = _EXPLICIT_RE.sub("", message)
|
|
127
|
+
return result.lstrip()
|