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