Files
chemenu/tools/chemenu/telemetry/scrub.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

170 lines
5.8 KiB
Python

"""Redaction applied to every trace event, in one place: the emitter.
The repo records prompts and assistant replies in cleartext, because a failure
taxonomy cannot be read out of hashes - that is the whole point of the
comprehension phase. Cleartext is only defensible with three guards, all of them
here rather than at each call site:
1. **Secret scrubbing.** Pattern-based, best effort, never a substitute for
discipline - `reports/` is gitignored and a secret scan runs in verification.
2. **A content cap**, so one 5 MB tool result cannot dominate a trace.
3. **A kill switch.** `WIKI_TRACE_CONTENT=0` drops model-facing text and keeps
only its length and SHA-256.
`raw/` file *contents* never reach a trace at all, whatever these settings say:
that text is data, not instruction (AGENTS.md invariant 4), and a trace is read
back later. Callers record a path plus a digest instead.
"""
from __future__ import annotations
import hashlib
import os
import re
# Sized like Claude Code's own OTel content limit (60 KiB), which is in turn
# sized for backends that cap an attribute at 64 KiB.
DEFAULT_MAX_CONTENT = 61440
# Attribute names holding model-facing text. Only these obey the kill switch;
# paths, tool names and exit codes stay readable either way.
CONTENT_KEYS = frozenset(
{"prompt", "response", "message", "text", "tool_input", "tool_output", "error"}
)
def _mask(name: str) -> str:
return f"[REDACTED:{name}]"
def _keep_key(name: str):
"""Replace the value of a `key: value` pair, keep the key readable."""
def repl(match: re.Match) -> str:
return f"{match.group(1)}{match.group(2)}{_mask(name)}"
return repl
# Order matters: the most specific pattern must match before a generic one can
# swallow part of it.
SECRET_PATTERNS: list[tuple[str, re.Pattern, object]] = [
(
"private-key",
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
None,
),
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), None),
("github-pat", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), None),
("aws-access-key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), None),
("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), None),
("anthropic-key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), None),
("openai-key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), None),
("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), None),
("onepassword-token", re.compile(r"\bops_[A-Za-z0-9+/=_-]{40,}\b"), None),
(
"jwt",
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
None,
),
(
# Stops at a quote, comma or whitespace after the credential: a tool
# input is usually one line of JSON, and eating to end-of-line would
# take the rest of the payload with the secret.
"auth-header",
re.compile(
r"""(?ix)
\b(authorization|x-api-key|proxy-authorization)
(\s*[:=]\s*)
(?:bearer|basic|token)?\s*
[^\s"',;\\]+
"""
),
"keep-key",
),
(
"secret-assignment",
re.compile(
r"""(?ix)
\b([A-Za-z0-9_.-]*
(?:api[_-]?key|secret|token|password|passwd|credential)
[A-Za-z0-9_.-]*)
(\s*[:=]\s*)
(["']?[^\s"',;]{8,}["']?)
"""
),
"keep-key",
),
]
def max_content() -> int:
raw = os.environ.get("WIKI_TRACE_MAX_CONTENT")
if raw and raw.isdigit() and int(raw) > 0:
return int(raw)
return DEFAULT_MAX_CONTENT
def content_enabled() -> bool:
"""Cleartext prompts/responses on by default; `WIKI_TRACE_CONTENT=0` opts out."""
return os.environ.get("WIKI_TRACE_CONTENT", "1") != "0"
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
def scrub_text(text: str) -> tuple[str, list[str]]:
"""Replace known secret shapes. Returns the text and the pattern names hit."""
hits: list[str] = []
for name, pattern, mode in SECRET_PATTERNS:
repl = _keep_key(name) if mode == "keep-key" else _mask(name)
text, count = pattern.subn(repl, text)
if count:
hits.append(name)
return text, hits
def cap_text(text: str, limit: int | None = None) -> str:
limit = limit or max_content()
if len(text) <= limit:
return text
dropped = len(text) - limit
return text[:limit] + f"... [TRUNCATED {dropped} chars]"
def _clean_string(value: str, hits: list[str]) -> str:
scrubbed, found = scrub_text(value)
hits.extend(h for h in found if h not in hits)
return cap_text(scrubbed)
def scrub_attrs(attrs: dict) -> tuple[dict, list[str]]:
"""Walk an attribute tree: scrub every string, cap every string, and apply
the content kill switch to the keys that hold model-facing text.
Content keys always gain `<key>_length` and `<key>_sha256` siblings, so two
traces recorded under different settings stay comparable.
"""
hits: list[str] = []
keep_content = content_enabled()
def walk(value):
if isinstance(value, dict):
out = {}
for key, item in value.items():
if key in CONTENT_KEYS and isinstance(item, str):
out[f"{key}_length"] = len(item)
out[f"{key}_sha256"] = sha256_text(item)
if keep_content:
out[key] = _clean_string(item, hits)
else:
out[key] = walk(item)
return out
if isinstance(value, list):
return [walk(item) for item in value]
if isinstance(value, str):
return _clean_string(value, hits)
return value
return walk(attrs), hits