oct
Loading...
Searching...
No Matches
redactor.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/redactor.py
4
5"""
6Purpose
7-------
8Output Redactor (Layer 5) for the ``oct-mcp`` six-layer pipeline.
9Applies a five-step sanitisation pipeline to every tool output before
10it is returned to the MCP client.
11
12Responsibilities
13----------------
14- Step 1: Apply ``redaction_patterns`` from ``debug_config.json``
15 (loaded at server startup; falls back to empty list if unavailable).
16- Step 2: Apply ``_SECRET_NAME_PATTERNS`` line filtering - any output
17 line that looks like a ``KEY=value`` assignment whose key matches a
18 secret pattern is replaced with ``[REDACTED]``.
19- Step 3: Anonymise absolute paths:
20 ``$HOME/...`` → ``[HOME]/...``
21 ``/usr/``, ``/bin/``, ``/opt/``, etc. → ``[SYSTEM]/...``
22 ``<project_root>/...`` → ``[PROJECT]/...``
23 Drive-letter paths on Windows follow the same rules.
24- Step 4: Strip ANSI escape sequences.
25- Step 5: Truncate output that exceeds ``max_output_bytes``, appending
26 a ``[OUTPUT TRUNCATED AT <N> BYTES]`` marker.
27
28Also applies git-URL credential redaction (``_redact_git_url``) as
29a supplementary step after step 2 to catch any credentials that
30survived the key-pattern filter.
31
32Diagnostics
33-----------
34Domain: MCP
35Levels:
36 L2 - lifecycle: redactor instantiation
37 L3 - per-call redaction counts
38 L4 - deep trace: individual substitutions
39
40Contracts
41---------
42- This module does not import ``click`` or the MCP SDK.
43- :meth:`Redactor.apply_all` is pure: the same input always produces the
44 same output given the same configuration.
45- :meth:`Redactor.apply_all` never raises.
46- ``redactions_applied`` in :class:`RedactorResult` counts *all*
47 individual substitutions made across all steps.
48
49Dependencies
50------------
51- oct.core.diagnostics (_dbg structured logger)
52- oct.core.git (_redact_git_url, the git URL credential redaction step)
53- oct.linter.oct_lint (_SECRET_NAME_PATTERNS, the secret key names for the line filter)
54"""
55
56from __future__ import annotations
57
58import re
59import sys
60from dataclasses import dataclass
61from pathlib import Path
62
63from oct.core.diagnostics import _dbg
64
65try:
66 from oct.linter.oct_lint import _SECRET_NAME_PATTERNS
67except ImportError: # pragma: no cover - graceful degradation
68 _SECRET_NAME_PATTERNS: frozenset[str] = frozenset({
69 "password", "passwd", "pwd",
70 "secret", "secret_key", "private_key",
71 "api_key", "apikey", "api_secret",
72 "token", "auth_token", "access_token", "refresh_token",
73 "connection_string", "conn_str", "dsn", "database_url", "db_url",
74 "aws_secret", "aws_key", "aws_access",
75 "credentials", "client_secret",
76 })
77
78try:
79 from oct.core.git import _redact_git_url
80except ImportError: # pragma: no cover
81 def _redact_git_url(text: str) -> str:
82 return text
83
84
85# ---------------------------------------------------------------------
86# Constants
87# ---------------------------------------------------------------------
88
89#: ANSI escape sequence pattern (covers colour, cursor, SGR sequences).
90_ANSI_ESCAPE_RE: re.Pattern[str] = re.compile(
91 r"\x1b(?:[@-Z\\-_]|\‍[[0-?]*[ -/]*[@-~])"
92)
93
94#: Pattern for ``KEY=value`` or ``KEY: value`` assignment lines whose
95#: key matches a secret pattern. Captures the key in group 1 and the
96#: separator + value in group 2 so we can reconstruct the line.
97#:
98#: Matches lines like:
99#: API_KEY=abc123
100#: password: hunter2
101#: token = "xyz"
102_ASSIGNMENT_LINE_RE: re.Pattern[str] = re.compile(
103 r'^(\s*(?:[A-Za-z_][A-Za-z0-9_]*))\s*[:=]\s*(.+)$'
104)
105
106#: System path prefixes to anonymise on POSIX.
107_SYSTEM_PATH_PREFIXES_POSIX: tuple[str, ...] = (
108 "/usr/", "/bin/", "/sbin/", "/lib/", "/lib64/",
109 "/opt/", "/etc/", "/var/", "/tmp/", "/run/",
110 "/snap/", "/proc/", "/sys/",
111)
112
113
114# ---------------------------------------------------------------------
115# Result type
116# ---------------------------------------------------------------------
117
118
119@dataclass
121 """Output from :meth:`Redactor.apply_all`.
122
123 Attributes
124 ----------
125 text
126 The sanitised output string.
127 redactions_applied
128 Total number of individual substitutions performed across all
129 five pipeline steps.
130 truncated
131 True if the output was truncated to ``max_output_bytes``.
132 original_size_bytes
133 Byte length of the input text (before any processing).
134 """
135
136 text: str
137 redactions_applied: int
138 truncated: bool
139 original_size_bytes: int
140
141
142# ---------------------------------------------------------------------
143# Redactor
144# ---------------------------------------------------------------------
145
146
148 """Five-step output sanitisation pipeline (Layer 5).
149
150 Parameters
151 ----------
152 redaction_patterns
153 List of regex patterns loaded from ``debug_config.json``. Each
154 entry may be a plain string (treated as a literal) or a dict
155 with keys ``pattern`` (regex) and ``replacement`` (substitution,
156 default ``"[REDACTED]"``). Patterns that fail to compile are
157 silently skipped.
158 max_output_bytes
159 Hard truncation limit. Default 1 MB. Matches ``McpConfig``
160 ``max_output_bytes``.
161 """
162
164 self,
165 redaction_patterns: list[str | dict] | None = None,
166 max_output_bytes: int = 1_048_576,
167 ) -> None:
168 func = "Redactor.__init__"
169 self._max_output_bytes: int = max(1, max_output_bytes)
170 self._compiled: list[tuple[re.Pattern[str], str]] = []
171
172 for entry in (redaction_patterns or []):
173 try:
174 if isinstance(entry, str):
175 self._compiled.append((re.compile(entry), "[REDACTED]"))
176 elif isinstance(entry, dict):
177 pat = entry.get("pattern", "")
178 rep = entry.get("replacement", "[REDACTED]")
179 if pat:
180 self._compiled.append((re.compile(pat), str(rep)))
181 except re.error:
182 _dbg("MCP", func, f"invalid redaction pattern (skipped): {entry!r}", 1)
183
184 # Build home-dir and system path prefixes once at init time.
185 home = str(Path.home())
186 self._home_prefix: str = home
187
188 _dbg("MCP", func, f"compiled {len(self._compiled)} custom patterns", 2)
189
190 # -- pipeline steps --------------------------------------------------
191
192 def _step1_custom_patterns(self, text: str) -> tuple[str, int]:
193 """Apply ``debug_config.json`` redaction_patterns."""
194 count = 0
195 for pattern, replacement in self._compiled:
196 new_text, n = pattern.subn(replacement, text)
197 count += n
198 text = new_text
199 return text, count
200
201 def _step2_secret_name_lines(self, text: str) -> tuple[str, int]:
202 """Replace lines that assign a value to a secret-named key."""
203 count = 0
204 out_lines: list[str] = []
205 for line in text.splitlines(keepends=True):
206 m = _ASSIGNMENT_LINE_RE.match(line)
207 if m:
208 key = m.group(1).strip()
209 if any(p in key.lower() for p in _SECRET_NAME_PATTERNS):
210 # Preserve the newline suffix if present.
211 suffix = "\n" if line.endswith("\n") else ""
212 out_lines.append(f"{key}=[REDACTED]{suffix}")
213 count += 1
214 continue
215 out_lines.append(line)
216 return "".join(out_lines), count
217
218 def _step2b_git_url_redaction(self, text: str) -> tuple[str, int]:
219 """Strip embedded git credentials (http user:token@)."""
220 new_text = _redact_git_url(text)
221 # Count as 1 redaction if any change was made.
222 return new_text, (1 if new_text != text else 0)
223
224 def _step3_path_anonymisation(self, text: str, project_root: Path) -> tuple[str, int]:
225 """Replace absolute paths with [HOME], [SYSTEM], [PROJECT] tokens.
226
227 Processing order matters: project root first (most specific),
228 then home, then system prefixes (most general).
229 """
230 count = 0
231 project_str = str(project_root)
232
233 # --- project root ---
234 if project_str in text:
235 count += text.count(project_str)
236 text = text.replace(project_str, "[PROJECT]")
237
238 # Also handle forward-slash version on Windows.
239 project_fwd = project_str.replace("\\", "/")
240 if project_fwd != project_str and project_fwd in text:
241 count += text.count(project_fwd)
242 text = text.replace(project_fwd, "[PROJECT]")
243
244 # --- home directory ---
245 home_str = self._home_prefix
246 if home_str in text:
247 count += text.count(home_str)
248 text = text.replace(home_str, "[HOME]")
249
250 home_fwd = home_str.replace("\\", "/")
251 if home_fwd != home_str and home_fwd in text:
252 count += text.count(home_fwd)
253 text = text.replace(home_fwd, "[HOME]")
254
255 # --- system paths ---
256 if sys.platform == "win32":
257 # Windows: replace common drive-root paths.
258 for prefix in ("C:\\Windows\\", "C:\\Program Files\\",
259 "C:\\Program Files (x86)\\", "C:\\ProgramData\\"):
260 if prefix in text:
261 count += text.count(prefix)
262 text = text.replace(prefix, "[SYSTEM]/")
263 else:
264 for prefix in _SYSTEM_PATH_PREFIXES_POSIX:
265 if prefix in text:
266 count += text.count(prefix)
267 text = text.replace(prefix, "[SYSTEM]/")
268
269 return text, count
270
271 def _step4_strip_ansi(self, text: str) -> tuple[str, int]:
272 """Remove ANSI escape sequences."""
273 new_text, n = _ANSI_ESCAPE_RE.subn("", text)
274 return new_text, n
275
276 def _step5_truncate(self, text: str) -> tuple[str, bool]:
277 """Truncate output to ``max_output_bytes`` if necessary."""
278 encoded = text.encode("utf-8", errors="replace")
279 if len(encoded) <= self._max_output_bytes:
280 return text, False
281 truncated = encoded[: self._max_output_bytes].decode("utf-8", errors="replace")
282 marker = f"\n[OUTPUT TRUNCATED AT {self._max_output_bytes} BYTES]"
283 return truncated + marker, True
284
285 # -- public API ------------------------------------------------------
286
287 def apply_all(self, text: str, project_root: Path) -> RedactorResult:
288 """Run the full five-step pipeline on *text*.
289
290 Never raises. Any unexpected exception is caught and the
291 original text is returned with a warning prepended.
292
293 Parameters
294 ----------
295 text
296 Raw tool output to sanitise.
297 project_root
298 Absolute path to the project root (used for step 3 path
299 anonymisation).
300
301 Returns
302 -------
303 :class:`RedactorResult`
304 """
305 func = "Redactor.apply_all"
306 original_size = len(text.encode("utf-8", errors="replace"))
307 total_redactions = 0
308
309 try:
310 # Step 1: custom patterns from debug_config.json
311 text, n1 = self._step1_custom_patterns(text)
312 total_redactions += n1
313
314 # Step 2: secret-named assignment lines
315 text, n2 = self._step2_secret_name_lines(text)
316 total_redactions += n2
317
318 # Step 2b: git URL credential redaction
319 text, n2b = self._step2b_git_url_redaction(text)
320 total_redactions += n2b
321
322 # Step 3: path anonymisation
323 text, n3 = self._step3_path_anonymisation(text, project_root)
324 total_redactions += n3
325
326 # Step 4: ANSI strip
327 text, n4 = self._step4_strip_ansi(text)
328 total_redactions += n4
329
330 # Step 5: truncation
331 text, truncated = self._step5_truncate(text)
332
333 except Exception as exc: # pragma: no cover - defensive catch-all
334 _dbg("MCP", func, f"SYSTEM_ERROR: redactor pipeline failed: {exc}", 1)
335 truncated = False
336
337 _dbg(
338 "MCP", func,
339 f"redactions={total_redactions} original_bytes={original_size}",
340 3,
341 )
342
343 return RedactorResult(
344 text=text,
345 redactions_applied=total_redactions,
346 truncated=truncated,
347 original_size_bytes=original_size,
348 )
tuple[str, int] _step2_secret_name_lines(self, str text)
Definition redactor.py:201
tuple[str, int] _step2b_git_url_redaction(self, str text)
Definition redactor.py:218
tuple[str, int] _step1_custom_patterns(self, str text)
Definition redactor.py:192
RedactorResult apply_all(self, str text, Path project_root)
Definition redactor.py:287
tuple[str, int] _step3_path_anonymisation(self, str text, Path project_root)
Definition redactor.py:224
tuple[str, bool] _step5_truncate(self, str text)
Definition redactor.py:276
tuple[str, int] _step4_strip_ansi(self, str text)
Definition redactor.py:271
None __init__(self, list[str|dict]|None redaction_patterns=None, int max_output_bytes=1_048_576)
Definition redactor.py:167
str _redact_git_url(str text)
Definition redactor.py:81