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