oclaw/runtime/chat/media_redact.py
oliver 31e3962c74 Harden attachment access controls and stabilize the Weixin bridge.
This persists referenced media safely, adds explicit attachment ACL/backfill flows, and fixes the Weixin sidecar so official login state can bridge reliably into oclaw without duplicate replays or dropped replies.

Made-with: Cursor
2026-04-28 15:38:55 +08:00

223 lines
7.7 KiB
Python

"""Strip/ingest embedded binary payloads from tool/MCP-shaped JSON.
Persistence is untouched; callers use copies when building model context."""
from __future__ import annotations
import base64
import os
from typing import Any
from oclaw.platform.files.attachment_assets import AttachmentAssetStore
_IMAGE_CONTENT_TYPES = frozenset({"image", "input_image"})
_BASE64_PAYLOAD_KEYS = ("data", "image_base64", "base64", "content_base64", "body_base64")
# Below this length we keep values (tiny icons / markers).
_MIN_B64_CHARS = 200
_DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
def redact_embedded_image_blobs(obj: Any) -> Any:
"""Deep-copy-ish transform: replace large base64 payloads with metadata placeholders."""
if isinstance(obj, dict):
return _redact_dict(obj)
if isinstance(obj, list):
return [redact_embedded_image_blobs(x) for x in obj]
return obj
def _looks_like_large_payload(s: str) -> bool:
t = str(s or "").strip()
if len(t) < _MIN_B64_CHARS:
return False
allowed = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r-_")
if not t[: min(512, len(t))]:
return False
noise = sum(1 for ch in t[: min(2000, len(t))] if ch not in allowed)
return noise <= max(2, len(t[: min(2000, len(t))]) // 200)
def _redact_dict(d: dict[str, Any]) -> dict[str, Any]:
typ = str(d.get("type") or "").strip().lower()
payload_keys = [k for k in _BASE64_PAYLOAD_KEYS if isinstance(d.get(k), str) and _looks_like_large_payload(str(d.get(k) or ""))]
if payload_keys:
plen = max(len(str(d.get(k) or "")) for k in payload_keys)
dup: dict[str, Any] = {}
for k, v in d.items():
if k in payload_keys:
continue
if isinstance(v, dict):
dup[k] = _redact_dict(v)
elif isinstance(v, list):
dup[k] = [redact_embedded_image_blobs(x) for x in v]
else:
dup[k] = v
is_image = typ in _IMAGE_CONTENT_TYPES
dup["_image_payload_redacted" if is_image else "_binary_payload_redacted"] = True
dup["_redacted_payload_chars"] = int(plen)
dup["_redacted_payload_keys"] = list(payload_keys)
return dup
out: dict[str, Any] = {}
for k, v in d.items():
if isinstance(v, dict):
out[k] = _redact_dict(v)
elif isinstance(v, list):
out[k] = [redact_embedded_image_blobs(x) for x in v]
else:
out[k] = v
return out
def ingest_embedded_image_blobs_as_refs(
obj: Any,
*,
root_dir: str | None = None,
filename_prefix: str = "tool-image",
) -> tuple[Any, list[dict[str, Any]]]:
"""Persist nested base64 blobs and replace them with attachment refs.
Returns transformed object and newly created attachment refs.
"""
store = AttachmentAssetStore(root_dir=root_dir) if root_dir else AttachmentAssetStore()
refs: list[dict[str, Any]] = []
def _ingest(node: Any, idx_seed: list[int]) -> Any:
if isinstance(node, list):
return [_ingest(x, idx_seed) for x in node]
if not isinstance(node, dict):
return node
typ = str(node.get("type") or "").strip().lower()
raw = _pick_base64_payload(node)
if raw:
blob = _decode_image_bytes(raw)
if blob:
max_bytes = _max_attachment_bytes()
if max_bytes > 0 and len(blob) > max_bytes:
redacted = _redact_dict(node)
redacted["type"] = _ref_type_for_mime(
str(node.get("mime") or node.get("mime_type") or "application/octet-stream"),
typ,
)
redacted["error"] = "attachment_too_large"
redacted["max_bytes"] = int(max_bytes)
redacted["actual_bytes"] = int(len(blob))
redacted.setdefault("name", str(node.get("name") or "attachment"))
redacted.setdefault("mime", str(node.get("mime") or node.get("mime_type") or "application/octet-stream"))
return redacted
idx_seed[0] += 1
mime = str(node.get("mime") or node.get("mime_type") or "image/png").strip() or "image/png"
ext = _filename_ext_for_mime(mime)
name = str(node.get("name") or f"{filename_prefix}-{idx_seed[0]}{ext}").strip()
meta = store.save_bytes(
blob,
filename=name,
mime=mime,
width=_safe_int(node.get("width")),
height=_safe_int(node.get("height")),
)
ref_type = _ref_type_for_mime(mime, typ)
ref = {
"type": ref_type,
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
refs.append(ref)
return ref
redacted = _redact_dict(node)
redacted["type"] = _ref_type_for_mime(
str(node.get("mime") or node.get("mime_type") or "application/octet-stream"),
typ,
)
redacted.setdefault("name", str(node.get("name") or "attachment"))
redacted.setdefault("mime", str(node.get("mime") or node.get("mime_type") or "application/octet-stream"))
return redacted
out: dict[str, Any] = {}
for k, v in node.items():
out[k] = _ingest(v, idx_seed)
return out
transformed = _ingest(obj, [0])
uniq: list[dict[str, Any]] = []
seen: set[str] = set()
for r in refs:
aid = str(r.get("attachment_id") or "").strip()
if not aid or aid in seen:
continue
seen.add(aid)
uniq.append(r)
return transformed, uniq
def _decode_image_bytes(raw: Any) -> bytes:
s = str(raw or "").strip()
if not s:
return b""
if s.startswith("data:") and ";base64," in s:
s = s.split(";base64,", 1)[1]
try:
return base64.b64decode(s.encode("ascii"), validate=False)
except Exception:
return b""
def _pick_base64_payload(node: dict[str, Any]) -> str:
for k in _BASE64_PAYLOAD_KEYS:
v = node.get(k)
if isinstance(v, str) and str(v).strip():
return v
return ""
def _ref_type_for_mime(mime: str, typ: str = "") -> str:
m = str(mime or "").strip().lower()
t = str(typ or "").strip().lower()
if t in _IMAGE_CONTENT_TYPES or m.startswith("image/"):
return "image_ref"
if m.startswith("video/"):
return "video_ref"
if m.startswith("text/"):
return "text_ref"
return "binary_ref"
def _filename_ext_for_mime(mime: str) -> str:
m = str(mime or "").strip().lower()
if m == "image/png":
return ".png"
if m in {"image/jpeg", "image/jpg"}:
return ".jpg"
if m == "image/webp":
return ".webp"
if m == "image/gif":
return ".gif"
if m == "video/mp4":
return ".mp4"
if m == "text/plain":
return ".txt"
return ".bin"
def _safe_int(raw: Any) -> int | None:
try:
if raw is None:
return None
return int(raw)
except Exception:
return None
def _max_attachment_bytes() -> int:
raw = str(os.getenv("AIA_MAX_ATTACHMENT_BYTES") or "").strip()
if raw.isdigit():
n = int(raw)
if n <= 0:
return 0
return min(n, 500 * 1024 * 1024)
return _DEFAULT_MAX_ATTACHMENT_BYTES
__all__ = ["redact_embedded_image_blobs", "ingest_embedded_image_blobs_as_refs"]