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/prompt.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
# Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
"""System prompt assembly for CIPHER gateway.
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- load_base_prompt: reads CLAUDE.md and strips the Trigger Keywords section
|
|
7
|
+
- assemble_system_prompt: composes full system prompt with mode-specific skill injection
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Repo root resolution
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def _find_repo_root(start: Path) -> Path:
|
|
21
|
+
"""Walk upward from *start* until a directory containing pyproject.toml is found."""
|
|
22
|
+
for candidate in [start] + list(start.parents):
|
|
23
|
+
if (candidate / "pyproject.toml").exists():
|
|
24
|
+
return candidate
|
|
25
|
+
raise FileNotFoundError(
|
|
26
|
+
f"Could not locate repo root from {start!r} (no pyproject.toml found)"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Cached at module import time — resolved from this file's location.
|
|
31
|
+
_MODULE_DIR = Path(__file__).resolve().parent
|
|
32
|
+
try:
|
|
33
|
+
REPO_ROOT: Path = _find_repo_root(_MODULE_DIR)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
REPO_ROOT = _MODULE_DIR.parent.parent # best-effort fallback
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Keyword table stripping
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# Matches from "#### Trigger Keywords" through the next horizontal rule or end of file.
|
|
43
|
+
# Uses re.DOTALL so `.` spans newlines.
|
|
44
|
+
_TRIGGER_KW_RE = re.compile(
|
|
45
|
+
r"####\s*Trigger Keywords.*?(?=\n---|\Z)",
|
|
46
|
+
re.DOTALL | re.IGNORECASE,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_base_prompt(repo_root: Path | None = None) -> str:
|
|
51
|
+
"""Read CLAUDE.md and strip the Trigger Keywords section.
|
|
52
|
+
|
|
53
|
+
The keyword table is stripped so the LLM cannot override gateway mode
|
|
54
|
+
routing by seeing the trigger mapping. CIPHER identity, operating mode
|
|
55
|
+
definitions, output format specs, and all other content are preserved.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
repo_root: Explicit repo root path. Defaults to the module-level REPO_ROOT.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Stripped CLAUDE.md content as a string.
|
|
62
|
+
"""
|
|
63
|
+
root = repo_root or REPO_ROOT
|
|
64
|
+
claude_md = root / "CLAUDE.md"
|
|
65
|
+
|
|
66
|
+
if not claude_md.exists():
|
|
67
|
+
logger.warning("load_base_prompt: CLAUDE.md not found at %s", claude_md)
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
text = claude_md.read_text(encoding="utf-8")
|
|
71
|
+
stripped = _TRIGGER_KW_RE.sub("", text)
|
|
72
|
+
# Collapse any triple+ blank lines left by the removal.
|
|
73
|
+
stripped = re.sub(r"\n{3,}", "\n\n", stripped).strip()
|
|
74
|
+
return stripped
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Skill map and loading
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Maps CIPHER mode names to SKILL.md paths relative to skills/.
|
|
82
|
+
MODE_SKILL_MAP: dict[str, str] = {
|
|
83
|
+
"RED": "red-team/SKILL.md",
|
|
84
|
+
"BLUE": "blue-team/SKILL.md",
|
|
85
|
+
"PURPLE": "purple-team/SKILL.md",
|
|
86
|
+
"PRIVACY": "privacy-engineering/SKILL.md",
|
|
87
|
+
"RECON": "osint-recon/SKILL.md",
|
|
88
|
+
"INCIDENT": "incident-response/SKILL.md",
|
|
89
|
+
"ARCHITECT": "security-architecture/SKILL.md",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Background layers injected per mode.
|
|
93
|
+
# RED always gets PURPLE as background; ALL modes get PRIVACY.
|
|
94
|
+
_BACKGROUND_LAYERS: dict[str, list[str]] = {
|
|
95
|
+
"RED": ["PURPLE", "PRIVACY"],
|
|
96
|
+
}
|
|
97
|
+
_DEFAULT_BACKGROUND: list[str] = ["PRIVACY"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_skill(mode: str, repo_root: Path | None = None) -> str:
|
|
101
|
+
"""Load the SKILL.md file for *mode*.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
mode: CIPHER mode name (e.g. "RED", "BLUE").
|
|
105
|
+
repo_root: Explicit repo root. Defaults to REPO_ROOT.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
SKILL.md contents, or empty string if mode unknown or file missing.
|
|
109
|
+
"""
|
|
110
|
+
root = repo_root or REPO_ROOT
|
|
111
|
+
rel_path = MODE_SKILL_MAP.get(mode.upper())
|
|
112
|
+
if rel_path is None:
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
skill_file = root / "skills" / rel_path
|
|
116
|
+
if not skill_file.exists():
|
|
117
|
+
logger.warning("load_skill: skill file not found at %s", skill_file)
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
return skill_file.read_text(encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def assemble_system_prompt(mode: str, repo_root: Path | None = None) -> str:
|
|
124
|
+
"""Assemble the full CIPHER system prompt for *mode*.
|
|
125
|
+
|
|
126
|
+
Composition:
|
|
127
|
+
1. Base prompt (CLAUDE.md with keyword table stripped)
|
|
128
|
+
2. Primary skill for the requested mode
|
|
129
|
+
3. Background layers (PURPLE for RED; PRIVACY for all modes)
|
|
130
|
+
|
|
131
|
+
Sections are joined with ``\\n\\n---\\n\\n``. Empty sections are omitted.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
mode: CIPHER operating mode (e.g. "RED", "BLUE", "ARCHITECT").
|
|
135
|
+
repo_root: Explicit repo root. Defaults to REPO_ROOT.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Assembled system prompt string.
|
|
139
|
+
"""
|
|
140
|
+
root = repo_root or REPO_ROOT
|
|
141
|
+
upper_mode = mode.upper()
|
|
142
|
+
|
|
143
|
+
sections: list[str] = []
|
|
144
|
+
|
|
145
|
+
# 1. Base prompt.
|
|
146
|
+
base = load_base_prompt(root)
|
|
147
|
+
if base:
|
|
148
|
+
sections.append(base)
|
|
149
|
+
|
|
150
|
+
# 2. Primary skill.
|
|
151
|
+
primary = load_skill(upper_mode, root)
|
|
152
|
+
if primary:
|
|
153
|
+
sections.append(primary)
|
|
154
|
+
|
|
155
|
+
# 3. Background layers.
|
|
156
|
+
backgrounds = _BACKGROUND_LAYERS.get(upper_mode, _DEFAULT_BACKGROUND)
|
|
157
|
+
for bg_mode in backgrounds:
|
|
158
|
+
if bg_mode == upper_mode:
|
|
159
|
+
# Never double-inject the primary skill.
|
|
160
|
+
continue
|
|
161
|
+
bg_skill = load_skill(bg_mode, root)
|
|
162
|
+
if bg_skill:
|
|
163
|
+
sections.append(bg_skill)
|
|
164
|
+
|
|
165
|
+
return "\n\n---\n\n".join(sections)
|
gateway/retriever.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
# Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
"""CIPHER RAG retriever — semantic search over the knowledge base.
|
|
4
|
+
|
|
5
|
+
Uses ChromaDB (embedded, persistent) to store and query document chunks.
|
|
6
|
+
Chunks are split on markdown headers to preserve semantic boundaries.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from gateway.retriever import Retriever
|
|
10
|
+
|
|
11
|
+
retriever = Retriever()
|
|
12
|
+
chunks = retriever.query("Kerberos AS-REP roasting", top_k=5)
|
|
13
|
+
context = retriever.format_context(chunks)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import chromadb
|
|
21
|
+
|
|
22
|
+
from gateway.prompt import REPO_ROOT
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Default persistent storage location.
|
|
27
|
+
_DEFAULT_DB_DIR = REPO_ROOT / ".chromadb"
|
|
28
|
+
_COLLECTION_NAME = "cipher_knowledge"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Retriever:
|
|
32
|
+
"""Semantic retriever over CIPHER knowledge base.
|
|
33
|
+
|
|
34
|
+
Wraps a ChromaDB persistent collection. On init, connects to the
|
|
35
|
+
existing store (or creates it if missing). Use ``ingest()`` to
|
|
36
|
+
populate or refresh the index from ``knowledge/`` files.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, db_dir: Path | None = None) -> None:
|
|
40
|
+
self._db_dir = db_dir or _DEFAULT_DB_DIR
|
|
41
|
+
self._client = chromadb.PersistentClient(path=str(self._db_dir))
|
|
42
|
+
self._collection = self._client.get_or_create_collection(
|
|
43
|
+
name=_COLLECTION_NAME,
|
|
44
|
+
metadata={"hnsw:space": "cosine"},
|
|
45
|
+
)
|
|
46
|
+
count = self._collection.count()
|
|
47
|
+
logger.info("Retriever: loaded collection %r (%d chunks)", _COLLECTION_NAME, count)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def count(self) -> int:
|
|
51
|
+
"""Number of chunks in the collection."""
|
|
52
|
+
return self._collection.count()
|
|
53
|
+
|
|
54
|
+
def query(self, text: str, top_k: int = 5) -> list[dict]:
|
|
55
|
+
"""Retrieve the most relevant chunks for *text*.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
text: Query string.
|
|
59
|
+
top_k: Maximum number of chunks to return.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of dicts with keys: document, source, distance.
|
|
63
|
+
"""
|
|
64
|
+
if self._collection.count() == 0:
|
|
65
|
+
logger.warning("Retriever: collection is empty — run 'cipher-gw ingest' first")
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
results = self._collection.query(
|
|
69
|
+
query_texts=[text],
|
|
70
|
+
n_results=min(top_k, self._collection.count()),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
chunks: list[dict] = []
|
|
74
|
+
for i, doc in enumerate(results["documents"][0]):
|
|
75
|
+
meta = results["metadatas"][0][i]
|
|
76
|
+
dist = results["distances"][0][i] if results.get("distances") else None
|
|
77
|
+
chunks.append({
|
|
78
|
+
"document": doc,
|
|
79
|
+
"source": meta.get("source", "unknown"),
|
|
80
|
+
"section": meta.get("section", ""),
|
|
81
|
+
"distance": dist,
|
|
82
|
+
})
|
|
83
|
+
return chunks
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def format_context(chunks: list[dict], max_tokens_estimate: int = 6000) -> str:
|
|
87
|
+
"""Format retrieved chunks into a context block for injection.
|
|
88
|
+
|
|
89
|
+
Estimates ~4 chars per token. Truncates to stay within budget.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
chunks: List of chunk dicts from query().
|
|
93
|
+
max_tokens_estimate: Rough token budget for the context block.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Formatted context string ready for system prompt injection.
|
|
97
|
+
"""
|
|
98
|
+
if not chunks:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
max_chars = max_tokens_estimate * 4
|
|
102
|
+
lines: list[str] = ["## Retrieved Knowledge Context\n"]
|
|
103
|
+
current_len = len(lines[0])
|
|
104
|
+
|
|
105
|
+
for chunk in chunks:
|
|
106
|
+
source = chunk["source"]
|
|
107
|
+
section = chunk.get("section", "")
|
|
108
|
+
header = f"### [{source}] {section}".strip()
|
|
109
|
+
entry = f"{header}\n{chunk['document']}\n"
|
|
110
|
+
|
|
111
|
+
if current_len + len(entry) > max_chars:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
lines.append(entry)
|
|
115
|
+
current_len += len(entry)
|
|
116
|
+
|
|
117
|
+
return "\n".join(lines)
|
|
118
|
+
|
|
119
|
+
def ingest(
|
|
120
|
+
self,
|
|
121
|
+
knowledge_dir: Path | None = None,
|
|
122
|
+
chunk_size: int = 1500,
|
|
123
|
+
chunk_overlap: int = 200,
|
|
124
|
+
) -> int:
|
|
125
|
+
"""Ingest knowledge files into the vector store.
|
|
126
|
+
|
|
127
|
+
Clears the existing collection and re-indexes from scratch.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
knowledge_dir: Path to knowledge/ directory. Defaults to REPO_ROOT/knowledge.
|
|
131
|
+
chunk_size: Target chunk size in characters.
|
|
132
|
+
chunk_overlap: Overlap between consecutive chunks in characters.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Number of chunks indexed.
|
|
136
|
+
"""
|
|
137
|
+
kb_dir = knowledge_dir or REPO_ROOT / "knowledge"
|
|
138
|
+
if not kb_dir.is_dir():
|
|
139
|
+
raise FileNotFoundError(f"Knowledge directory not found: {kb_dir}")
|
|
140
|
+
|
|
141
|
+
md_files = sorted(kb_dir.glob("*.md"))
|
|
142
|
+
if not md_files:
|
|
143
|
+
raise FileNotFoundError(f"No .md files found in {kb_dir}")
|
|
144
|
+
|
|
145
|
+
logger.info("Retriever: ingesting %d files from %s", len(md_files), kb_dir)
|
|
146
|
+
|
|
147
|
+
# Clear existing data.
|
|
148
|
+
self._client.delete_collection(_COLLECTION_NAME)
|
|
149
|
+
self._collection = self._client.get_or_create_collection(
|
|
150
|
+
name=_COLLECTION_NAME,
|
|
151
|
+
metadata={"hnsw:space": "cosine"},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
total_chunks = 0
|
|
155
|
+
for md_file in md_files:
|
|
156
|
+
filename = md_file.name
|
|
157
|
+
text = md_file.read_text(encoding="utf-8")
|
|
158
|
+
chunks = _split_markdown(text, filename, chunk_size, chunk_overlap)
|
|
159
|
+
|
|
160
|
+
if not chunks:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
ids = [f"{filename}::{i}" for i in range(len(chunks))]
|
|
164
|
+
documents = [c["text"] for c in chunks]
|
|
165
|
+
metadatas = [{"source": c["source"], "section": c["section"]} for c in chunks]
|
|
166
|
+
|
|
167
|
+
# ChromaDB has a batch limit; add in batches of 500.
|
|
168
|
+
for batch_start in range(0, len(ids), 500):
|
|
169
|
+
batch_end = batch_start + 500
|
|
170
|
+
self._collection.add(
|
|
171
|
+
ids=ids[batch_start:batch_end],
|
|
172
|
+
documents=documents[batch_start:batch_end],
|
|
173
|
+
metadatas=metadatas[batch_start:batch_end],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
total_chunks += len(chunks)
|
|
177
|
+
logger.debug(" %s: %d chunks", filename, len(chunks))
|
|
178
|
+
|
|
179
|
+
logger.info("Retriever: ingested %d chunks from %d files", total_chunks, len(md_files))
|
|
180
|
+
return total_chunks
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _split_markdown(
|
|
184
|
+
text: str,
|
|
185
|
+
filename: str,
|
|
186
|
+
chunk_size: int = 1500,
|
|
187
|
+
chunk_overlap: int = 200,
|
|
188
|
+
) -> list[dict]:
|
|
189
|
+
"""Split a markdown document into chunks on header boundaries.
|
|
190
|
+
|
|
191
|
+
Strategy:
|
|
192
|
+
1. Split on ## and ### headers (keeping higher-level context).
|
|
193
|
+
2. If a section exceeds chunk_size, split it further at paragraph
|
|
194
|
+
boundaries with overlap.
|
|
195
|
+
3. Each chunk carries its source filename and section header.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
text: Full markdown text.
|
|
199
|
+
filename: Source filename for metadata.
|
|
200
|
+
chunk_size: Target chunk size in characters.
|
|
201
|
+
chunk_overlap: Overlap between chunks when splitting long sections.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of dicts with keys: text, source, section.
|
|
205
|
+
"""
|
|
206
|
+
import re
|
|
207
|
+
|
|
208
|
+
chunks: list[dict] = []
|
|
209
|
+
|
|
210
|
+
# Split on markdown headers (## or ###), keeping the header with its section.
|
|
211
|
+
sections = re.split(r"(?=^#{1,3}\s)", text, flags=re.MULTILINE)
|
|
212
|
+
|
|
213
|
+
current_header = filename.replace(".md", "").replace("-", " ").title()
|
|
214
|
+
|
|
215
|
+
for section in sections:
|
|
216
|
+
section = section.strip()
|
|
217
|
+
if not section:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Extract section header if present.
|
|
221
|
+
header_match = re.match(r"^(#{1,3})\s+(.+)", section)
|
|
222
|
+
if header_match:
|
|
223
|
+
current_header = header_match.group(2).strip()
|
|
224
|
+
|
|
225
|
+
if len(section) <= chunk_size:
|
|
226
|
+
chunks.append({
|
|
227
|
+
"text": section,
|
|
228
|
+
"source": filename,
|
|
229
|
+
"section": current_header,
|
|
230
|
+
})
|
|
231
|
+
else:
|
|
232
|
+
# Split long sections at paragraph boundaries.
|
|
233
|
+
paragraphs = re.split(r"\n\n+", section)
|
|
234
|
+
buffer = ""
|
|
235
|
+
for para in paragraphs:
|
|
236
|
+
if buffer and len(buffer) + len(para) + 2 > chunk_size:
|
|
237
|
+
chunks.append({
|
|
238
|
+
"text": buffer.strip(),
|
|
239
|
+
"source": filename,
|
|
240
|
+
"section": current_header,
|
|
241
|
+
})
|
|
242
|
+
# Keep overlap from the end of the buffer.
|
|
243
|
+
if chunk_overlap > 0:
|
|
244
|
+
buffer = buffer[-chunk_overlap:] + "\n\n" + para
|
|
245
|
+
else:
|
|
246
|
+
buffer = para
|
|
247
|
+
else:
|
|
248
|
+
buffer = buffer + "\n\n" + para if buffer else para
|
|
249
|
+
|
|
250
|
+
if buffer.strip():
|
|
251
|
+
chunks.append({
|
|
252
|
+
"text": buffer.strip(),
|
|
253
|
+
"source": filename,
|
|
254
|
+
"section": current_header,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return chunks
|
gateway/theme.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
# Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
"""CIPHER cyberpunk theme — colors, styles, and output helpers."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.theme import Theme
|
|
8
|
+
|
|
9
|
+
# Cyberpunk color palette
|
|
10
|
+
COLORS = {
|
|
11
|
+
"primary": "#00FF41", # Matrix green
|
|
12
|
+
"secondary": "#0D0D0D", # Near-black background
|
|
13
|
+
"accent": "#00E5CC", # Cyan accent
|
|
14
|
+
"success": "#00E5CC", # Cyan
|
|
15
|
+
"warning": "#FFB020", # Amber
|
|
16
|
+
"error": "#FF4D4D", # Coral red
|
|
17
|
+
"muted": "#5A6480", # Dim gray
|
|
18
|
+
# Mode-specific
|
|
19
|
+
"mode.red": "#FF2D2D", # Offensive
|
|
20
|
+
"mode.blue": "#2D9BFF", # Defensive
|
|
21
|
+
"mode.purple": "#9B59B6", # Detection engineering
|
|
22
|
+
"mode.privacy": "#27AE60", # Privacy
|
|
23
|
+
"mode.recon": "#F39C12", # OSINT
|
|
24
|
+
"mode.incident": "#E74C3C", # Incident response
|
|
25
|
+
"mode.architect": "#3498DB", # Architecture
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
MODE_COLORS: dict[str, str] = {
|
|
29
|
+
"RED": COLORS["mode.red"],
|
|
30
|
+
"BLUE": COLORS["mode.blue"],
|
|
31
|
+
"PURPLE": COLORS["mode.purple"],
|
|
32
|
+
"PRIVACY": COLORS["mode.privacy"],
|
|
33
|
+
"RECON": COLORS["mode.recon"],
|
|
34
|
+
"INCIDENT": COLORS["mode.incident"],
|
|
35
|
+
"ARCHITECT": COLORS["mode.architect"],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cipher_theme = Theme({
|
|
39
|
+
"cipher.primary": COLORS["primary"],
|
|
40
|
+
"cipher.accent": COLORS["accent"],
|
|
41
|
+
"cipher.success": COLORS["success"],
|
|
42
|
+
"cipher.warning": COLORS["warning"],
|
|
43
|
+
"cipher.error": COLORS["error"],
|
|
44
|
+
"cipher.muted": COLORS["muted"],
|
|
45
|
+
"cipher.mode.red": COLORS["mode.red"],
|
|
46
|
+
"cipher.mode.blue": COLORS["mode.blue"],
|
|
47
|
+
"cipher.mode.purple": COLORS["mode.purple"],
|
|
48
|
+
"cipher.mode.privacy": COLORS["mode.privacy"],
|
|
49
|
+
"cipher.mode.recon": COLORS["mode.recon"],
|
|
50
|
+
"cipher.mode.incident": COLORS["mode.incident"],
|
|
51
|
+
"cipher.mode.architect": COLORS["mode.architect"],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
console = Console(theme=cipher_theme, highlight=False)
|
|
55
|
+
err_console = Console(theme=cipher_theme, stderr=True, highlight=False)
|
|
56
|
+
|
|
57
|
+
BANNER = r"""[cipher.primary]
|
|
58
|
+
██████╗██╗██████╗ ██╗ ██╗███████╗██████╗
|
|
59
|
+
██╔════╝██║██╔══██╗██║ ██║██╔════╝██╔══██╗
|
|
60
|
+
██║ ██║██████╔╝███████║█████╗ ██████╔╝
|
|
61
|
+
██║ ██║██╔═══╝ ██╔══██║██╔══╝ ██╔══██╗
|
|
62
|
+
╚██████╗██║██║ ██║ ██║███████╗██║ ██║
|
|
63
|
+
╚═════╝╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝[/]
|
|
64
|
+
[cipher.muted]Security Engineering Assistant[/]
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def print_banner() -> None:
|
|
69
|
+
"""Print the CIPHER ASCII banner."""
|
|
70
|
+
console.print(BANNER)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def print_success(msg: str) -> None:
|
|
74
|
+
console.print(f" [cipher.success]✓[/] {msg}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def print_error(msg: str) -> None:
|
|
78
|
+
console.print(f" [cipher.error]✗[/] {msg}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def print_warn(msg: str) -> None:
|
|
82
|
+
console.print(f" [cipher.warning]⚠[/] {msg}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print_info(msg: str) -> None:
|
|
86
|
+
console.print(f" [cipher.muted]●[/] {msg}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mode_style(mode: str) -> str:
|
|
90
|
+
"""Return the Rich style string for a CIPHER mode."""
|
|
91
|
+
return f"cipher.mode.{mode.lower()}" if f"cipher.mode.{mode.lower()}" in cipher_theme.styles else "cipher.primary"
|