← Files Portable ResumeARCHIVED FILE

skills/.portable-resume/runtime/portable_resume/sanitize.py

16.7 KB · Oct 4, 2026 · 12:33 UTC

↓ Download file

"""Deterministic removal of privileged records, controls, binary data, and obvious secrets."""

from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass
from typing import Any, Mapping

from .bounds import DEFAULT_BOUNDS, Bounds
from .diagnostics import WARNING_CODES
from .model import Turn

_ANSI = re.compile(r"(?:\x1B\[[0-?]*[ -/]*[@-~]|\x1B\][^\x07]*(?:\x07|\x1B\\))")
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
    re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
    re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"),
    re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
    re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*"),
    re.compile(r"(?i)(\b(?:password|passwd|api[_-]?key|access[_-]?token|token|secret)\s*[:=]\s*)[^\s,;]+"),
    re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----"),
    re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b"),
    re.compile(r"\bAIza[0-9A-Za-z_-]{20,}\b"),
)
_FORBIDDEN_ROLES = frozenset({"system", "developer", "reasoning", "thinking", "control", "internal"})
_FORBIDDEN_KEYS = (
    "system_prompt",
    "developer",
    "reasoning",
    "thinking",
    "signature",
    "encrypted",
    "ciphertext",
    "private_key",
    "credentials",
)
_BIDI_ZERO_WIDTH = frozenset(
    {
        "\u061c",
        "\u200b",
        "\u200c",
        "\u200d",
        "\u200e",
        "\u200f",
        "\u202a",
        "\u202b",
        "\u202c",
        "\u202d",
        "\u202e",
        "\u2060",
        "\u2061",
        "\u2062",
        "\u2063",
        "\u2064",
        "\u2066",
        "\u2067",
        "\u2068",
        "\u2069",
        "\ufeff",
    }
)


@dataclass(frozen=True, slots=True)
class SanitizedText:
    text: str
    warnings: tuple[str, ...] = ()
    truncated: bool = False


def _dedupe(values: list[str]) -> tuple[str, ...]:
    return tuple(dict.fromkeys(value for value in values if value in WARNING_CODES))


def sanitize_text(value: str | bytes, *, max_chars: int, binary: bool = False) -> SanitizedText:
    """Return bounded printable NFC text; binary containers are omitted, never expanded."""

    warnings: list[str] = []
    if binary:
        return SanitizedText("", ("W_BINARY_OMITTED",), False)
    if isinstance(value, bytes):
        decoded = value.decode("utf-8", errors="replace")
        if "\ufffd" in decoded:
            warnings.append("W_CONTROLS_REMOVED")
        text = decoded
    elif isinstance(value, str):
        text = value
    else:
        return SanitizedText("", ("W_BINARY_OMITTED",), False)

    replaced = _ANSI.sub("", text)
    if replaced != text:
        warnings.append("W_CONTROLS_REMOVED")
    text = replaced.replace("\r\n", "\n").replace("\r", "\n")
    cleaned: list[str] = []
    controls_removed = False
    for char in text:
        point = ord(char)
        if char in _BIDI_ZERO_WIDTH or (point < 0x20 and char not in "\n\t") or 0x7F <= point <= 0x9F:
            controls_removed = True
            continue
        cleaned.append(char)
    if controls_removed:
        warnings.append("W_CONTROLS_REMOVED")
    text = unicodedata.normalize("NFC", "".join(cleaned))

    redacted = text
    for pattern in _SECRET_PATTERNS:
        if pattern.pattern.startswith("(?i)(\\b"):
            redacted, count = pattern.subn(r"\1[REDACTED]", redacted)
        else:
            redacted, count = pattern.subn("[REDACTED]", redacted)
        if count:
            warnings.append("W_METADATA_REDACTED")
    truncated = len(redacted) > max_chars
    if truncated:
        redacted = redacted[:max_chars]
        warnings.append("W_TRUNCATED")
    return SanitizedText(redacted, _dedupe(warnings), truncated)


def sanitize_inline(value: str | bytes, *, max_chars: int) -> SanitizedText:
    """Sanitize one recovered metadata value into a single inert display line."""

    cleaned = sanitize_text(value, max_chars=max_chars)
    warnings = list(cleaned.warnings)
    single_line = " ".join(cleaned.text.replace("\t", " ").splitlines())
    if single_line != cleaned.text:
        warnings.append("W_CONTROLS_REMOVED")
    if len(single_line) > max_chars:
        single_line = single_line[:max_chars]
        warnings.append("W_TRUNCATED")
    return SanitizedText(single_line, _dedupe(warnings), cleaned.truncated or "W_TRUNCATED" in warnings)


def validate_structural_identity(value: str, *, max_chars: int) -> str:
    """Validate a selection/identity field without display redaction or rewrite.

    Used for native session IDs and similar tokens that must remain byte-stable
    for exact selection and adapter round-trips. Secret-shaped IDs are kept as-is
    (they are selection keys, not free-text metadata). Overlong values fail closed
    rather than truncating into a different identity.
    """

    from .diagnostics import DiagnosticError

    if not isinstance(value, str) or not value:
        raise DiagnosticError("E_INVARIANT")
    if "\x00" in value or any(ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F for ch in value):
        raise DiagnosticError("E_INVARIANT")
    if any(ch in _BIDI_ZERO_WIDTH for ch in value):
        raise DiagnosticError("E_INVARIANT")
    if len(value) > max_chars:
        raise DiagnosticError.limit_exceeded()
    return value


def validate_structural_summary(summary: "SessionSummary", *, bounds: Bounds = DEFAULT_BOUNDS) -> "SessionSummary":
    """Fail closed when adapter structural identity cannot be used for selection."""

    from .model import SessionSummary

    if not isinstance(summary, SessionSummary):
        from .diagnostics import DiagnosticError

        raise DiagnosticError("E_INVARIANT")
    session_id = validate_structural_identity(summary.session_id, max_chars=bounds.ref_chars)
    for field_name in ("cwd", "source_path", "source_repo_root"):
        field_value = getattr(summary, field_name)
        if field_value is None:
            continue
        if not isinstance(field_value, str):
            from .diagnostics import DiagnosticError

            raise DiagnosticError("E_INVARIANT")
        if "\x00" in field_value or any(
            ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F for ch in field_value
        ):
            from .diagnostics import DiagnosticError

            raise DiagnosticError("E_INVARIANT")
        if len(field_value) > 4096:
            from .diagnostics import DiagnosticError

            raise DiagnosticError.limit_exceeded()
    if session_id is summary.session_id:
        return summary
    # session_id was validated in place; rebuild only if a future validator rewrites.
    return SessionSummary(
        source=summary.source,
        session_id=session_id,
        source_path=summary.source_path,
        title=summary.title,
        cwd=summary.cwd,
        branch=summary.branch,
        created_at=summary.created_at,
        updated_at=summary.updated_at,
        source_repo_root=summary.source_repo_root,
        provider=summary.provider,
        warnings=summary.warnings,
    )


def sanitize_metadata(value: Mapping[str, Any], *, max_depth: int = 8) -> tuple[dict[str, Any], tuple[str, ...]]:
    """Allow printable scalar/list/map metadata while dropping privileged containers."""

    warnings: list[str] = []

    def walk(item: Any, depth: int) -> Any:
        if depth > max_depth:
            warnings.append("W_TRUNCATED")
            return None
        if isinstance(item, Mapping):
            output: dict[str, Any] = {}
            for raw_key in sorted(item, key=lambda entry: str(entry)):
                key = str(raw_key)
                lowered = key.casefold().replace("-", "_")
                if any(fragment in lowered for fragment in _FORBIDDEN_KEYS) or lowered in {
                    "password",
                    "passwd",
                    "api_key",
                    "access_token",
                    "secret",
                }:
                    warnings.append("W_METADATA_REDACTED")
                    continue
                output[key] = walk(item[raw_key], depth + 1)
            return output
        if isinstance(item, (list, tuple)):
            return [walk(entry, depth + 1) for entry in item[:256]]
        if isinstance(item, bytes):
            warnings.append("W_BINARY_OMITTED")
            return None
        if isinstance(item, str):
            cleaned = sanitize_text(item, max_chars=4096)
            warnings.extend(cleaned.warnings)
            return cleaned.text
        if item is None or isinstance(item, (bool, int, float)):
            return item
        warnings.append("W_BINARY_OMITTED")
        return None

    result = walk(value, 0)
    return (result if isinstance(result, dict) else {}), _dedupe(warnings)


def sanitize_turn_record(
    record: Mapping[str, Any],
    *,
    ordinal: int,
    bounds: Bounds = DEFAULT_BOUNDS,
) -> tuple[Turn | None, tuple[str, ...]]:
    """Convert an allowlisted record into an inert turn or drop privileged/binary records."""

    role = str(record.get("role", "")).casefold()
    if role in _FORBIDDEN_ROLES or role not in {"user", "assistant", "tool"}:
        return None, ()
    content = record.get("content", "")
    binary = bool(record.get("binary", False)) or str(record.get("content_type", "")).casefold().startswith(
        ("application/octet-stream", "image/", "audio/", "video/")
    )
    maximum = bounds.tool_output_chars if role == "tool" else bounds.normalized_content_bytes
    cleaned = sanitize_text(content, max_chars=maximum, binary=binary)
    if binary:
        return None, cleaned.warnings
    tool_name: str | None = None
    if role == "tool" and isinstance(record.get("tool_name"), str):
        tool_name = sanitize_text(record["tool_name"], max_chars=256).text or None
    timestamp = record.get("timestamp") if isinstance(record.get("timestamp"), str) else None
    turn = Turn(
        ordinal=ordinal,
        role=role,
        content=cleaned.text,
        timestamp=timestamp,
        tool_name=tool_name,
        truncated=cleaned.truncated,
    )
    return turn, cleaned.warnings


def sanitize_summary(summary: "SessionSummary", *, bounds: Bounds = DEFAULT_BOUNDS) -> tuple["SessionSummary", tuple[str, ...]]:
    """Project a public/display summary. Selection must use the raw summary.

    ``session_id`` is a selection token: validated without secret redaction so
    exact follow-up stays possible. Free-text and path fields are redacted and
    bounded for public envelopes.
    """

    from .model import SessionSummary

    warnings: list[str] = []
    title = None
    if summary.title is not None:
        cleaned = sanitize_inline(summary.title, max_chars=bounds.title_chars)
        title = cleaned.text or None
        warnings.extend(cleaned.warnings)
    branch = None
    if summary.branch is not None:
        cleaned = sanitize_inline(summary.branch, max_chars=512)
        branch = cleaned.text or None
        warnings.extend(cleaned.warnings)
    session_id = validate_structural_identity(summary.session_id, max_chars=bounds.ref_chars)
    cwd = sanitize_inline(summary.cwd, max_chars=4096) if summary.cwd is not None else None
    source_path = sanitize_inline(summary.source_path, max_chars=4096) if summary.source_path is not None else None
    source_repo_root = (
        sanitize_inline(summary.source_repo_root, max_chars=4096)
        if summary.source_repo_root is not None
        else None
    )
    for cleaned_path in (cwd, source_path, source_repo_root):
        if cleaned_path is not None:
            warnings.extend(cleaned_path.warnings)
    result = SessionSummary(
        source=summary.source,
        session_id=session_id,
        source_path=source_path.text if source_path is not None else None,
        title=title,
        cwd=cwd.text if cwd is not None else None,
        branch=branch,
        created_at=summary.created_at,
        updated_at=summary.updated_at,
        source_repo_root=source_repo_root.text if source_repo_root is not None else None,
        provider=summary.provider,
        warnings=tuple(dict.fromkeys((*summary.warnings, *warnings))),
    )
    # Include carried summary warnings (e.g. adapter W_TRUNCATED) in the
    # returned set so reader envelopes promote them to top-level warnings.
    return result, _dedupe([*summary.warnings, *warnings])


def sanitize_session(session: "Session", *, bounds: Bounds = DEFAULT_BOUNDS) -> "Session":
    """Re-sanitize typed adapter output so policy cannot diverge per provider."""

    from .model import Session

    warnings: list[str] = list(session.warnings)
    title = sanitize_inline(session.title or "", max_chars=bounds.title_chars)
    branch = sanitize_inline(session.branch or "", max_chars=512)
    # Selection identity token: never secret-redact native session IDs.
    session_id_text = validate_structural_identity(session.session_id, max_chars=bounds.ref_chars)
    cwd = sanitize_inline(session.cwd, max_chars=4096) if session.cwd is not None else None
    source_path = sanitize_inline(session.source_path, max_chars=4096) if session.source_path is not None else None
    source_repo_root = (
        sanitize_inline(session.source_repo_root, max_chars=4096)
        if session.source_repo_root is not None
        else None
    )
    last_user = sanitize_text(session.last_user_request or "", max_chars=bounds.normalized_content_bytes)
    last_assistant = sanitize_text(session.last_assistant_action or "", max_chars=bounds.normalized_content_bytes)
    warnings.extend((*title.warnings, *branch.warnings, *last_user.warnings, *last_assistant.warnings))
    for cleaned_path in (cwd, source_path, source_repo_root):
        if cleaned_path is not None:
            warnings.extend(cleaned_path.warnings)
    last_user_text, user_truncated = _take_utf8(last_user.text, bounds.normalized_content_bytes)
    remaining = bounds.normalized_content_bytes - len(last_user_text.encode("utf-8"))
    last_assistant_text, assistant_truncated = _take_utf8(last_assistant.text, remaining)
    if user_truncated or assistant_truncated:
        warnings.append("W_TRUNCATED")
    turns: list[Turn] = []
    total = len(last_user_text.encode("utf-8")) + len(last_assistant_text.encode("utf-8"))
    for source_turn in session.turns[: bounds.normalized_turns]:
        if total >= bounds.normalized_content_bytes:
            warnings.append("W_TRUNCATED")
            break
        if source_turn.role not in {"user", "assistant", "tool"}:
            continue
        maximum = bounds.tool_output_chars if source_turn.role == "tool" else bounds.normalized_content_bytes
        cleaned = sanitize_text(source_turn.content, max_chars=maximum)
        warnings.extend(cleaned.warnings)
        encoded = cleaned.text.encode("utf-8")
        if total + len(encoded) > bounds.normalized_content_bytes:
            remaining = max(0, bounds.normalized_content_bytes - total)
            bounded = encoded[:remaining]
            while True:
                try:
                    content = bounded.decode("utf-8")
                    break
                except UnicodeDecodeError:
                    bounded = bounded[:-1]
            warnings.append("W_TRUNCATED")
            truncated = True
        else:
            content = cleaned.text
            truncated = cleaned.truncated or source_turn.truncated
        total += len(content.encode("utf-8"))
        tool_name = sanitize_inline(source_turn.tool_name, max_chars=256) if source_turn.tool_name else None
        if tool_name is not None:
            warnings.extend(tool_name.warnings)
        turns.append(
            Turn(
                ordinal=len(turns),
                role=source_turn.role,
                content=content,
                timestamp=source_turn.timestamp,
                tool_name=tool_name.text if tool_name is not None else None,
                truncated=truncated,
            )
        )
        if total >= bounds.normalized_content_bytes:
            break
    if len(session.turns) > len(turns):
        warnings.append("W_TRUNCATED")
    return Session(
        source=session.source,
        session_id=session_id_text,
        source_path=source_path.text if source_path is not None else None,
        title=title.text or None,
        cwd=cwd.text if cwd is not None else None,
        branch=branch.text or None,
        created_at=session.created_at,
        updated_at=session.updated_at,
        source_repo_root=source_repo_root.text if source_repo_root is not None else None,
        last_user_request=last_user_text or None,
        last_assistant_action=last_assistant_text or None,
        turns=tuple(turns),
        warnings=_dedupe(warnings),
    )


def _take_utf8(text: str, maximum_bytes: int) -> tuple[str, bool]:
    encoded = text.encode("utf-8")
    if len(encoded) <= maximum_bytes:
        return text, False
    bounded = encoded[: max(0, maximum_bytes)]
    while bounded:
        try:
            return bounded.decode("utf-8"), True
        except UnicodeDecodeError:
            bounded = bounded[:-1]
    return "", bool(encoded)

SHA-256: 8291fa01665975ce309019c32d08263cfb49756da919df8a9cfd43f09d539f34