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.
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.
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.
36 L2 — lifecycle: redactor instantiation
37 L3 — per-call redaction counts
38 L4 — deep trace: individual substitutions
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.
50from __future__
import annotations
54from dataclasses
import dataclass
55from pathlib
import Path
58 from oc_diagnostics
import _dbg
as _real_dbg
60 def _dbg(*args, **kwargs) -> None:
61 _real_dbg(*args, **kwargs)
63 def _dbg(*args, **kwargs) -> None:
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",
80 from oct.core.git
import _redact_git_url
91_ANSI_ESCAPE_RE: re.Pattern[str] = re.compile(
92 r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"
103_ASSIGNMENT_LINE_RE: re.Pattern[str] = re.compile(
104 r'^(\s*(?:[A-Za-z_][A-Za-z0-9_]*))\s*[:=]\s*(.+)$'
108_SYSTEM_PATH_PREFIXES_POSIX: tuple[str, ...] = (
109 "/usr/",
"/bin/",
"/sbin/",
"/lib/",
"/lib64/",
110 "/opt/",
"/etc/",
"/var/",
"/tmp/",
"/run/",
111 "/snap/",
"/proc/",
"/sys/",
122 """Output from :meth:`Redactor.apply_all`.
127 The sanitised output string.
129 Total number of individual substitutions performed across all
132 True if the output was truncated to ``max_output_bytes``.
134 Byte length of the input text (before any processing).
138 redactions_applied: int
140 original_size_bytes: int
149 """Five-step output sanitisation pipeline (Layer 5).
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
160 Hard truncation limit. Default 1 MB. Matches ``McpConfig``
161 ``max_output_bytes``.
166 redaction_patterns: list[str | dict] |
None =
None,
167 max_output_bytes: int = 1_048_576,
169 func =
"Redactor.__init__"
173 for entry
in (redaction_patterns
or []):
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]")
181 self.
_compiled.append((re.compile(pat), str(rep)))
183 _dbg(
"MCP", func, f
"invalid redaction pattern (skipped): {entry!r}", 1)
186 home = str(Path.home())
189 _dbg(
"MCP", func, f
"compiled {len(self._compiled)} custom patterns", 2)
194 """Apply ``debug_config.json`` redaction_patterns."""
196 for pattern, replacement
in self.
_compiled:
197 new_text, n = pattern.subn(replacement, text)
203 """Replace lines that assign a value to a secret-named key."""
205 out_lines: list[str] = []
206 for line
in text.splitlines(keepends=
True):
207 m = _ASSIGNMENT_LINE_RE.match(line)
209 key = m.group(1).strip()
210 if any(p
in key.lower()
for p
in _SECRET_NAME_PATTERNS):
212 suffix =
"\n" if line.endswith(
"\n")
else ""
213 out_lines.append(f
"{key}=[REDACTED]{suffix}")
216 out_lines.append(line)
217 return "".join(out_lines), count
220 """Strip embedded git credentials (http user:token@)."""
223 return new_text, (1
if new_text != text
else 0)
226 """Replace absolute paths with [HOME], [SYSTEM], [PROJECT] tokens.
228 Processing order matters: project root first (most specific),
229 then home, then system prefixes (most general).
232 project_str = str(project_root)
235 if project_str
in text:
236 count += text.count(project_str)
237 text = text.replace(project_str,
"[PROJECT]")
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]")
248 count += text.count(home_str)
249 text = text.replace(home_str,
"[HOME]")
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]")
257 if sys.platform ==
"win32":
259 for prefix
in (
"C:\\Windows\\",
"C:\\Program Files\\",
260 "C:\\Program Files (x86)\\",
"C:\\ProgramData\\"):
262 count += text.count(prefix)
263 text = text.replace(prefix,
"[SYSTEM]/")
265 for prefix
in _SYSTEM_PATH_PREFIXES_POSIX:
267 count += text.count(prefix)
268 text = text.replace(prefix,
"[SYSTEM]/")
273 """Remove ANSI escape sequences."""
274 new_text, n = _ANSI_ESCAPE_RE.subn(
"", text)
278 """Truncate output to ``max_output_bytes`` if necessary."""
279 encoded = text.encode(
"utf-8", errors=
"replace")
283 marker = f
"\n[OUTPUT TRUNCATED AT {self._max_output_bytes} BYTES]"
284 return truncated + marker,
True
288 def apply_all(self, text: str, project_root: Path) -> RedactorResult:
289 """Run the full five-step pipeline on *text*.
291 Never raises. Any unexpected exception is caught and the
292 original text is returned with a warning prepended.
297 Raw tool output to sanitise.
299 Absolute path to the project root (used for step 3 path
304 :class:`RedactorResult`
306 func =
"Redactor.apply_all"
307 original_size = len(text.encode(
"utf-8", errors=
"replace"))
313 total_redactions += n1
317 total_redactions += n2
321 total_redactions += n2b
325 total_redactions += n3
329 total_redactions += n4
334 except Exception
as exc:
335 _dbg(
"MCP", func, f
"SYSTEM_ERROR: redactor pipeline failed: {exc}", 1)
340 f
"redactions={total_redactions} original_bytes={original_size}",
346 redactions_applied=total_redactions,
348 original_size_bytes=original_size,
tuple[str, int] _step2_secret_name_lines(self, str text)
tuple[str, int] _step2b_git_url_redaction(self, str text)
tuple[str, int] _step1_custom_patterns(self, str text)
RedactorResult apply_all(self, str text, Path project_root)
tuple[str, int] _step3_path_anonymisation(self, str text, Path project_root)
tuple[str, bool] _step5_truncate(self, str text)
tuple[str, int] _step4_strip_ansi(self, str text)
None __init__(self, list[str|dict]|None redaction_patterns=None, int max_output_bytes=1_048_576)
str _redact_git_url(str text)
None _dbg(*args, **kwargs)