18ae28f918
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.
170 lines
5.8 KiB
Python
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
|