feat(security): implement S35/R77/S34 wave2 with process-isolated transforms, tamper-evident persistence, and observability tier enforcement

This commit is contained in:
rookiestar28
2026-02-13 21:21:24 +08:00
parent ac68cf2b11
commit 9252f1b397
24 changed files with 1583 additions and 432 deletions
+3 -1
View File
@@ -69,7 +69,9 @@ jobs:
- name: Install test deps
run: |
python -m pip install --upgrade pip
python -m pip install numpy pillow
# Keep aligned with local pre-push/full-test scripts.
# aiohttp is required by multiple unit-test import paths.
python -m pip install numpy pillow aiohttp
- name: Run unit tests
env:
+9 -4
View File
@@ -67,7 +67,10 @@ if web is not None:
# CRITICAL: These imports MUST remain present.
# If edited out, module-level placeholders stay as None and handlers raise at runtime
# (e.g., TypeError: 'NoneType' object is not callable), producing noisy aiohttp tracebacks.
from ..services.access_control import require_observability_access
from ..services.access_control import (
require_admin_token,
require_observability_access,
)
from ..services.log_tail import tail_log
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
@@ -109,6 +112,7 @@ if web is not None:
# IMPORTANT: keep PACK_* imports aligned with config.py (VERSION/config_path do not exist).
from config import LOG_FILE, PACK_NAME, PACK_START_TIME, PACK_VERSION
from services.access_control import require_admin_token # type: ignore
from services.access_control import require_observability_access # type: ignore
from services.log_tail import tail_log # type: ignore
from services.metrics import metrics # type: ignore
@@ -261,8 +265,8 @@ async def logs_tail_handler(request: web.Request) -> web.Response:
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
# S14: Access Control
allowed, error = require_observability_access(request)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
@@ -364,7 +368,8 @@ async def trace_handler(request: web.Request) -> web.Response:
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = require_observability_access(request)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
+51 -9
View File
@@ -39,6 +39,41 @@ def _import_aiohttp_web():
return aiohttp, web
class _CompatResponse:
"""Minimal response shim for unit tests when aiohttp is unavailable."""
def __init__(
self,
*,
status: int = 200,
text: str = "",
content_type: str = "text/plain",
body: Optional[bytes] = None,
):
self.status = status
self.text = text
self.content_type = content_type
self.body = body if body is not None else text.encode("utf-8")
def _make_response(web, *, status: int = 200, text: str = "OK"):
if web is not None:
return web.Response(status=status, text=text)
return _CompatResponse(status=status, text=text)
def _make_json_response(web, data: dict, *, status: int = 200):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
if web is not None:
return web.json_response(data, status=status)
return _CompatResponse(
status=status,
text=body.decode("utf-8"),
content_type="application/json",
body=body,
)
class KakaoWebhookServer:
"""
KakaoTalk payload adapter.
@@ -112,14 +147,15 @@ class KakaoWebhookServer:
async def handle_webhook(self, request):
"""POST handler for Kakao Skill payloads."""
_, web = _import_aiohttp_web()
if web is None:
raise RuntimeError("aiohttp not available")
# IMPORTANT:
# CI unit tests call this handler directly without aiohttp installed.
# Keep this path runnable; do not replace with a hard RuntimeError.
try:
body_bytes = await request.read()
payload = json.loads(body_bytes)
except json.JSONDecodeError:
return web.Response(status=400, text="Bad JSON")
return _make_response(web, status=400, text="Bad JSON")
# S32: Replay Guard (Content Hash Dedup)
# We use a hash of the body bytes as the "nonce" for deduplication.
@@ -127,9 +163,8 @@ class KakaoWebhookServer:
content_hash = hashlib.sha256(body_bytes).hexdigest()
if not self._replay_guard.check_and_record(content_hash):
logger.warning(f"Replay rejected for Kakao hash: {content_hash}")
return web.Response(
status=200, text="OK"
) # Return 200 to stop Kakao retries
# Return 200 to stop Kakao retries
return _make_response(web, status=200, text="OK")
# Normalization
# userRequest.user.id is the opaque user ID (botUserKey)
@@ -169,8 +204,15 @@ class KakaoWebhookServer:
try:
resp = await self.router.handle(req)
if resp.text:
return self._build_text_response(resp.text)
# IMPORTANT:
# Router mocks in unit tests may return non-string `.text` values.
# Normalize defensively to avoid turning a valid routing flow into
# a JSON serialization error path.
resp_text = getattr(resp, "text", "")
if not isinstance(resp_text, str):
resp_text = str(resp_text) if resp_text is not None else ""
if resp_text:
return self._build_text_response(resp_text)
else:
# No response content (e.g. valid command but no output intended?)
# Kakao requires *some* response payload or it treats as error.
@@ -192,7 +234,7 @@ class KakaoWebhookServer:
"version": "2.0",
"template": {"outputs": [{"simpleText": {"text": text}}]},
}
return web.json_response(resp_data)
return _make_json_response(web, resp_data)
def _build_error_response(self, error_msg: str):
"""Build simple error text response."""
+46 -14
View File
@@ -47,6 +47,35 @@ def _import_aiohttp_web():
return aiohttp, web
class _CompatResponse:
"""Minimal response shim for unit tests when aiohttp is unavailable."""
def __init__(
self,
*,
status: int = 200,
text: str = "",
content_type: str = "text/plain",
body: Optional[bytes] = None,
):
self.status = status
self.text = text
self.content_type = content_type
self.body = body if body is not None else text.encode("utf-8")
def _make_response(
web,
*,
status: int = 200,
text: str = "",
content_type: str = "text/plain",
):
if web is not None:
return web.Response(status=status, text=text, content_type=content_type)
return _CompatResponse(status=status, text=text, content_type=content_type)
# ---------------------------------------------------------------------------
# R74 — Protocol constants
# ---------------------------------------------------------------------------
@@ -343,8 +372,9 @@ class WeChatWebhookServer:
Must return echostr as plain text if signature is valid.
"""
_, web = _import_aiohttp_web()
if web is None:
raise RuntimeError("aiohttp not available")
# IMPORTANT:
# CI unit tests invoke handler logic directly without aiohttp installed.
# Do not hard-raise here; return compat responses so security logic remains testable.
signature = request.query.get("signature", "")
timestamp = request.query.get("timestamp", "")
@@ -355,10 +385,10 @@ class WeChatWebhookServer:
if verify_wechat_signature(token, timestamp, nonce, signature):
logger.info("WeChat webhook verification succeeded")
return web.Response(text=echostr, content_type="text/plain")
return _make_response(web, text=echostr, content_type="text/plain")
logger.warning("WeChat webhook verification failed")
return web.Response(status=403, text="Verification failed")
return _make_response(web, status=403, text="Verification failed")
# ------------------------------------------------------------------
# POST — Inbound Messages (R74 + S31)
@@ -367,8 +397,9 @@ class WeChatWebhookServer:
async def handle_webhook(self, request):
"""POST handler for WeChat XML messages/events."""
_, web = _import_aiohttp_web()
if web is None:
raise RuntimeError("aiohttp not available")
# IMPORTANT:
# Keep handler behavior testable in environments without aiohttp.
# Server startup still requires aiohttp, but direct handler unit tests should not crash.
# S31: Signature verification
signature = request.query.get("signature", "")
@@ -378,12 +409,12 @@ class WeChatWebhookServer:
if not verify_wechat_signature(token, timestamp, nonce, signature):
logger.warning("Invalid WeChat POST signature")
return web.Response(status=401, text="Invalid Signature")
return _make_response(web, status=401, text="Invalid Signature")
# S31: Replay protection — nonce dedup
if nonce and not self._replay_guard.check_and_record(nonce):
logger.warning(f"Replay rejected for WeChat nonce: {nonce}")
return web.Response(status=403, text="Replay Rejected")
return _make_response(web, status=403, text="Replay Rejected")
# S31: Timestamp freshness
try:
@@ -394,7 +425,7 @@ class WeChatWebhookServer:
age_sec = now - ts_val
if age_sec > self.REPLAY_WINDOW_SEC or age_sec < -60:
logger.warning(f"Stale WeChat request: age={age_sec}s")
return web.Response(status=403, text="Stale Request")
return _make_response(web, status=403, text="Stale Request")
# Read and parse XML with S31 budgets
body_bytes = await request.read()
@@ -403,13 +434,13 @@ class WeChatWebhookServer:
fields = parse_wechat_xml(body_bytes)
except XMLBudgetExceeded as e:
logger.warning(f"WeChat XML budget exceeded: {e}")
return web.Response(status=400, text="Bad Request")
return _make_response(web, status=400, text="Bad Request")
# R74: Normalize to canonical event
event = normalize_wechat_event(fields)
if event is None:
# Unsupported message type — return empty success to WeChat
return web.Response(text="success", content_type="text/plain")
return _make_response(web, text="success", content_type="text/plain")
# S31: Allowlist check (soft-deny)
sender_id = event["sender_id"]
@@ -429,7 +460,7 @@ class WeChatWebhookServer:
# Per-message dedup (MsgId-based, distinct from nonce-based replay)
if message_id and not self._replay_guard.check_and_record(f"msg:{message_id}"):
logger.debug(f"Duplicate WeChat MsgId: {message_id}")
return web.Response(text="success", content_type="text/plain")
return _make_response(web, text="success", content_type="text/plain")
req = CommandRequest(
platform="wechat",
@@ -448,14 +479,15 @@ class WeChatWebhookServer:
to_user = event["sender_id"]
from_user = event["to_user"]
reply_xml = build_text_reply_xml(to_user, from_user, resp.text)
return web.Response(
return _make_response(
web,
text=reply_xml,
content_type="application/xml",
)
except Exception as e:
logger.exception(f"Error handling WeChat command: {e}")
return web.Response(text="success", content_type="text/plain")
return _make_response(web, text="success", content_type="text/plain")
# ------------------------------------------------------------------
# Outbound: Text (Customer Service Message API)
+32
View File
@@ -0,0 +1,32 @@
"""
Debug script for S35 Transform Isolation.
Verifies that the correct executor (TransformProcessRunner) is allowed/loaded.
"""
import os
import sys
# Ensure project root is in path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from services.constrained_transforms import get_transform_executor
from services.transform_runner import TransformProcessRunner
def main():
print("Checking S35 Transform Executor...")
executor = get_transform_executor()
print(f"Executor Type: {type(executor)}")
if isinstance(executor, TransformProcessRunner):
print("SUCCESS: TransformProcessRunner is active (Isolation Enabled).")
else:
print("WARNING: TransformProcessRunner is NOT active.")
# Check if feature flag is enabled
from services.transform_common import is_transforms_enabled
print(f"Feature Flag Enabled: {is_transforms_enabled()}")
if __name__ == "__main__":
main()
+75 -20
View File
@@ -40,13 +40,50 @@ require_cmd() {
fi
}
pip_install_or_fail() {
local why="$1"
shift
if "$VENV_PY" -m pip install "$@"; then
return 0
fi
echo "[pre-push] ERROR: failed to install dependency ($why): $*" >&2
exit 1
}
is_wsl() {
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
}
select_venv_dir() {
# Explicit override for advanced/local setups.
if [ -n "${OPENCLAW_TEST_VENV:-}" ]; then
echo "$OPENCLAW_TEST_VENV"
return 0
fi
case "$UNAME_S" in
MINGW*|MSYS*|CYGWIN*)
echo "$ROOT_DIR/.venv"
;;
*)
# IMPORTANT:
# In WSL, prefer dedicated Linux venv to avoid mixing with Windows .venv.
if is_wsl; then
echo "$ROOT_DIR/.venv-wsl"
else
echo "$ROOT_DIR/.venv"
fi
;;
esac
}
resolve_venv_python() {
case "$UNAME_S" in
MINGW*|MSYS*|CYGWIN*)
echo "$ROOT_DIR/.venv/Scripts/python.exe"
echo "$VENV_DIR/Scripts/python.exe"
;;
*)
echo "$ROOT_DIR/.venv/bin/python"
echo "$VENV_DIR/bin/python"
;;
esac
}
@@ -70,24 +107,24 @@ bootstrap_venv() {
fi
if [ -e "$venv_py" ]; then
echo "[pre-push] WARN: existing .venv is invalid; recreating with a Windows-native Python." >&2
rm -rf "$ROOT_DIR/.venv"
echo "[pre-push] WARN: existing venv is invalid; recreating: $VENV_DIR" >&2
rm -rf "$VENV_DIR"
fi
echo "[pre-push] INFO: creating project .venv ..." >&2
echo "[pre-push] INFO: creating project venv at $VENV_DIR ..." >&2
case "$UNAME_S" in
MINGW*|MSYS*|CYGWIN*)
# CRITICAL: on Git Bash, `python3` may resolve to MSYS `/usr/bin/python`,
# which creates a broken Windows venv (`No Python at "/usr/bin\python.exe"`).
# Always prefer Windows-native launchers/interpreters.
if command -v py.exe >/dev/null 2>&1; then
py.exe -3 -m venv "$ROOT_DIR/.venv"
py.exe -3 -m venv "$VENV_DIR"
elif [ -x "/c/Windows/py.exe" ]; then
/c/Windows/py.exe -3 -m venv "$ROOT_DIR/.venv"
/c/Windows/py.exe -3 -m venv "$VENV_DIR"
elif command -v python.exe >/dev/null 2>&1; then
python.exe -m venv "$ROOT_DIR/.venv"
python.exe -m venv "$VENV_DIR"
elif command -v py >/dev/null 2>&1; then
py -3 -m venv "$ROOT_DIR/.venv"
py -3 -m venv "$VENV_DIR"
else
echo "[pre-push] ERROR: no Windows Python launcher found (py.exe/python.exe)." >&2
exit 1
@@ -95,9 +132,9 @@ bootstrap_venv() {
;;
*)
if command -v python3 >/dev/null 2>&1; then
python3 -m venv "$ROOT_DIR/.venv"
python3 -m venv "$VENV_DIR"
elif command -v python >/dev/null 2>&1; then
python -m venv "$ROOT_DIR/.venv"
python -m venv "$VENV_DIR"
else
echo "[pre-push] ERROR: no bootstrap Python found (python3/python)." >&2
exit 1
@@ -106,7 +143,7 @@ bootstrap_venv() {
esac
if ! is_venv_python_healthy "$venv_py"; then
echo "[pre-push] ERROR: failed to initialize project .venv." >&2
echo "[pre-push] ERROR: failed to initialize project venv: $VENV_DIR" >&2
exit 1
fi
echo "$venv_py"
@@ -116,19 +153,33 @@ pre_commit_cmd() {
"$VENV_PY" -m pre_commit "$@"
}
# CRITICAL: pre-push must always run pre-commit from project .venv.
# CRITICAL: pre-push must always run pre-commit from project venv.
# Do not switch this back to global `pre-commit` command lookup.
# This prevents mixed global/user installs from hijacking hook execution.
VENV_DIR="$(select_venv_dir)"
VENV_PY="$(bootstrap_venv)"
if ! "$VENV_PY" -m pre_commit --version >/dev/null 2>&1; then
echo "[pre-push] INFO: installing pre-commit into project .venv ..." >&2
"$VENV_PY" -m pip install -U pip pre-commit
echo "[pre-push] INFO: installing pre-commit into project venv ($VENV_DIR) ..." >&2
pip_install_or_fail "required for pre-commit hooks" -U pip pre-commit
fi
if ! "$VENV_PY" -c "import black" >/dev/null 2>&1; then
# Keep black in the same interpreter used by local black-single hook.
echo "[pre-push] INFO: installing black into project .venv ..." >&2
"$VENV_PY" -m pip install black==24.1.1
echo "[pre-push] INFO: installing black into project venv ($VENV_DIR) ..." >&2
pip_install_or_fail "required by black-single hook" black==24.1.1
fi
# IMPORTANT:
# Pre-push now runs backend unit tests. Keep minimal runtime deps aligned with
# CI unit-test job to avoid "passes locally, fails on GitHub" drift.
if ! "$VENV_PY" -c "import numpy, PIL" >/dev/null 2>&1; then
echo "[pre-push] INFO: installing numpy/pillow into project venv ($VENV_DIR) ..." >&2
pip_install_or_fail "required by unit tests" numpy pillow
fi
if ! "$VENV_PY" -c "import aiohttp" >/dev/null 2>&1; then
echo "[pre-push] INFO: installing aiohttp into project venv ($VENV_DIR) ..." >&2
pip_install_or_fail "required by unit tests/import paths" aiohttp
fi
require_cmd npm
run_pre_commit_safe() {
@@ -225,13 +276,17 @@ if [ "$NODE_MAJOR" -lt 18 ]; then
fi
echo "[pre-push] Node version: $(node -v)"
echo "[pre-push] 1/3 detect-secrets"
echo "[pre-push] 1/4 detect-secrets"
run_pre_commit_safe run detect-secrets --all-files
echo "[pre-push] 2/3 pre-commit all hooks"
echo "[pre-push] 2/4 pre-commit all hooks"
run_pre_commit_safe run --all-files
echo "[pre-push] 3/3 npm test (Playwright)"
echo "[pre-push] 3/4 backend unit tests"
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_unit" \
"$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py"
echo "[pre-push] 4/4 npm test (Playwright)"
npm test
echo "[pre-push] PASS"
+47 -13
View File
@@ -6,30 +6,64 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export BLACK_CACHE_DIR="${BLACK_CACHE_DIR:-$ROOT_DIR/.tmp/black-cache}"
mkdir -p "$BLACK_CACHE_DIR"
# CRITICAL: Always prefer project-local .venv interpreter for Black.
# CRITICAL: Always prefer project-local venv interpreter for Black.
# Without this, Windows can accidentally pick global Python (e.g. C:\Program Files\Python312)
# where `black` is not installed, causing flaky pre-commit failures.
# DO NOT change this back to "python/python3 from PATH first" unless you also
# guarantee black is installed in every global interpreter used by contributors.
is_wsl() {
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
}
can_use_python() {
local candidate="$1"
[ -f "$candidate" ] || return 1
"$candidate" -c "import sys; print(sys.executable)" >/dev/null 2>&1
}
has_project_venv=false
[ -d "$ROOT_DIR/.venv" ] && has_project_venv=true
select_venv_dirs() {
local dirs=()
if [ -n "${OPENCLAW_TEST_VENV:-}" ]; then
dirs+=("$OPENCLAW_TEST_VENV")
fi
if is_wsl; then
dirs+=("$ROOT_DIR/.venv-wsl")
fi
dirs+=("$ROOT_DIR/.venv")
printf '%s\n' "${dirs[@]}"
}
if can_use_python "$ROOT_DIR/.venv/Scripts/python.exe"; then
PY_CMD="$ROOT_DIR/.venv/Scripts/python.exe"
elif can_use_python "$ROOT_DIR/.venv/bin/python"; then
PY_CMD="$ROOT_DIR/.venv/bin/python"
elif [ "$has_project_venv" = true ]; then
# CRITICAL: if .venv exists but is broken, fail fast instead of silently
# falling back to a random global Python (which reintroduces flakiness).
# DO NOT relax this guard; it is intentional to prevent silent environment drift.
echo "ERROR: project .venv exists but Python is unusable. Recreate .venv and retry." >&2
exit 1
check_venv_dir() {
local dir="$1"
if can_use_python "$dir/Scripts/python.exe"; then
PY_CMD="$dir/Scripts/python.exe"
return 0
fi
if can_use_python "$dir/bin/python"; then
PY_CMD="$dir/bin/python"
return 0
fi
return 1
}
while IFS= read -r vdir; do
[ -n "$vdir" ] || continue
if [ -d "$vdir" ]; then
if check_venv_dir "$vdir"; then
break
fi
# IMPORTANT:
# If a preferred project venv exists but is unusable, fail fast instead of
# silently falling back to random global Python (prevents environment drift).
echo "ERROR: project venv exists but Python is unusable: $vdir" >&2
echo "Recreate this venv and retry." >&2
exit 1
fi
done < <(select_venv_dirs)
if [ -n "${PY_CMD:-}" ]; then
:
elif command -v python >/dev/null 2>&1; then
# Fallback chain only for environments that intentionally do not use .venv.
PY_CMD="$(command -v python)"
+27 -7
View File
@@ -22,6 +22,25 @@ require_cmd() {
fi
}
is_wsl() {
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
}
select_venv_dir() {
# Explicit override for advanced/local setups.
if [ -n "${OPENCLAW_TEST_VENV:-}" ]; then
echo "$OPENCLAW_TEST_VENV"
return 0
fi
# IMPORTANT:
# In WSL, prefer dedicated Linux venv to avoid clashing with Windows .venv.
if is_wsl; then
echo "$ROOT_DIR/.venv-wsl"
else
echo "$ROOT_DIR/.venv"
fi
}
pip_install_or_fail() {
local why="$1"
shift
@@ -30,7 +49,7 @@ pip_install_or_fail() {
fi
echo "[tests] ERROR: failed to install dependency ($why): $*" >&2
echo "[tests] HINT: check internet/proxy, then retry the script." >&2
echo "[tests] HINT: if offline, pre-install into .venv manually: $VENV_PY -m pip install $*" >&2
echo "[tests] HINT: if offline, pre-install into venv manually: $VENV_PY -m pip install $*" >&2
exit 1
}
@@ -38,13 +57,14 @@ require_cmd node
require_cmd npm
# Always use project-local venv to avoid global interpreter / tool drift.
VENV_PY="$ROOT_DIR/.venv/bin/python"
VENV_DIR="$(select_venv_dir)"
VENV_PY="$VENV_DIR/bin/python"
if [ ! -x "$VENV_PY" ]; then
echo "[tests] Creating project venv at $ROOT_DIR/.venv ..."
echo "[tests] Creating project venv at $VENV_DIR ..."
if command -v python3 >/dev/null 2>&1; then
python3 -m venv "$ROOT_DIR/.venv"
python3 -m venv "$VENV_DIR"
elif command -v python >/dev/null 2>&1; then
python -m venv "$ROOT_DIR/.venv"
python -m venv "$VENV_DIR"
else
echo "[tests] ERROR: no bootstrap Python found (need python3 or python)" >&2
exit 1
@@ -52,12 +72,12 @@ if [ ! -x "$VENV_PY" ]; then
fi
if ! "$VENV_PY" -m pre_commit --version >/dev/null 2>&1; then
echo "[tests] Installing pre-commit into project venv ..."
echo "[tests] Installing pre-commit into project venv ($VENV_DIR) ..."
pip_install_or_fail "required for detect-secrets and hook validation" -U pip pre-commit
fi
if ! "$VENV_PY" -c "import aiohttp" >/dev/null 2>&1; then
echo "[tests] Installing aiohttp into project venv ..."
echo "[tests] Installing aiohttp into project venv ($VENV_DIR) ..."
pip_install_or_fail "required by import paths used in unit tests" aiohttp
fi
+17 -6
View File
@@ -57,16 +57,19 @@ def _atomic_write(path: str, data: Dict) -> None:
raise
from ..integrity import IntegrityError, load_verified, save_verified
def load_approvals() -> Dict[str, ApprovalRequest]:
"""Load approvals from disk."""
"""Load approvals from disk with integrity check."""
path = _get_approvals_path()
if not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# R77: Load verification
data = load_verified(path, expected_version=1, migrate=True)
result = {}
if isinstance(data, dict) and "approvals" in data:
@@ -79,13 +82,20 @@ def load_approvals() -> Dict[str, ApprovalRequest]:
logger.info(f"Loaded {len(result)} approval records")
return result
except (json.JSONDecodeError, OSError) as e:
except IntegrityError as e:
# R77: Fail-closed logic with escalation
logger.critical(
f"R77: Integrity violation detected in approvals file {path}: {e}"
)
# Return empty (deny all pending approvals) which is safe fail-state
return {}
except Exception as e:
logger.error(f"Failed to load approvals: {e}")
return {}
def save_approvals(approvals: Dict[str, ApprovalRequest]) -> bool:
"""Save approvals to disk."""
"""Save approvals to disk with integrity envelope."""
path = _get_approvals_path()
try:
@@ -94,7 +104,8 @@ def save_approvals(approvals: Dict[str, ApprovalRequest]) -> bool:
"saved_at": datetime.now(timezone.utc).isoformat(),
"approvals": [a.to_dict() for a in approvals.values()],
}
_atomic_write(path, data)
# R77: Atomic verified save
save_verified(path, data, version=1)
return True
except Exception as e:
logger.error(f"Failed to save approvals: {e}")
+18 -9
View File
@@ -18,6 +18,8 @@ except ImportError:
# Fallback for tests or decoupled run
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
from .integrity import IntegrityError, load_verified, save_verified
logger = logging.getLogger("ComfyUI-OpenClaw.services.checkpoints")
CHECKPOINTS_DIR = os.path.join(DATA_DIR, "checkpoints")
@@ -45,9 +47,14 @@ def list_checkpoints() -> List[Dict[str, Any]]:
for entry in it:
if entry.name.endswith(".meta.json") and entry.is_file():
try:
with open(entry.path, "r", encoding="utf-8") as f:
meta = json.load(f)
checkpoints.append(meta)
meta = load_verified(entry.path, migrate=True)
checkpoints.append(meta)
except IntegrityError as e:
logger.critical(
f"R77: Integrity violation in checkpoint {entry.name}: {e}"
)
# Skip this file (fail-closed for this item)
except Exception:
logger.warning(f"Failed to read checkpoint meta: {entry.name}")
except OSError:
@@ -66,12 +73,14 @@ def get_checkpoint(checkpoint_id: str) -> Optional[Dict[str, Any]]:
return None
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
with open(payload_path, "r", encoding="utf-8") as f:
workflow = json.load(f)
meta = load_verified(meta_path, migrate=True)
workflow = load_verified(payload_path, migrate=True)
return {"id": checkpoint_id, "meta": meta, "workflow": workflow}
except IntegrityError as e:
logger.critical(f"R77: Integrity violation in checkpoint {checkpoint_id}: {e}")
return None
except Exception as e:
logger.error(f"Error reading checkpoint {checkpoint_id}: {e}")
return None
@@ -148,8 +157,8 @@ def create_checkpoint(
meta_path, payload_path = _get_paths(cid)
try:
_atomic_write(meta_path, json.dumps(meta, indent=2))
_atomic_write(payload_path, workflow_json)
save_verified(meta_path, meta)
save_verified(payload_path, workflow)
except Exception as e:
# Cleanup on fail (although atomic write minimizes this risk for individual files)
if os.path.exists(meta_path):
+63 -332
View File
@@ -16,311 +16,55 @@ Default posture: DISABLED. Requires OPENCLAW_ENABLE_TRANSFORMS=1.
from __future__ import annotations
import hashlib
import importlib
import importlib.util
import json
import logging
import os
import signal
import sys
import threading
import time
from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set
from typing import Any, Dict, List, Optional
from .transform_common import (
_FEATURE_FLAG,
DEFAULT_MAX_OUTPUT_BYTES,
DEFAULT_MAX_TRANSFORMS_PER_REQUEST,
DEFAULT_TRANSFORM_TIMEOUT_SEC,
MAX_TRANSFORM_MODULE_SIZE_BYTES,
TransformLimits,
TransformRegistry,
TransformRegistryError,
TransformResult,
TransformStatus,
TrustedTransform,
get_transform_registry,
is_transforms_enabled,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.constrained_transforms")
# ---------------------------------------------------------------------------
# Feature gate
# ---------------------------------------------------------------------------
_FEATURE_FLAG = "OPENCLAW_ENABLE_TRANSFORMS"
def is_transforms_enabled() -> bool:
"""Check if constrained transforms are enabled (default: OFF)."""
val = os.environ.get(_FEATURE_FLAG, "").strip().lower()
return val in ("1", "true", "yes", "on")
# ---------------------------------------------------------------------------
# Runtime limits
# ---------------------------------------------------------------------------
DEFAULT_TRANSFORM_TIMEOUT_SEC = 5
DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 # 64KB
DEFAULT_MAX_TRANSFORMS_PER_REQUEST = 5
MAX_TRANSFORM_MODULE_SIZE_BYTES = 50 * 1024 # 50KB — prevent loading huge scripts
@dataclass
class TransformLimits:
"""Runtime limits for transform execution."""
timeout_sec: float = DEFAULT_TRANSFORM_TIMEOUT_SEC
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
max_transforms_per_request: int = DEFAULT_MAX_TRANSFORMS_PER_REQUEST
@classmethod
def from_env(cls) -> "TransformLimits":
"""Load limits from environment variables."""
def _env_int(key: str, default: int) -> int:
try:
return int(os.environ.get(key, str(default)))
except (ValueError, TypeError):
return default
def _env_float(key: str, default: float) -> float:
try:
return float(os.environ.get(key, str(default)))
except (ValueError, TypeError):
return default
return cls(
timeout_sec=_env_float(
"OPENCLAW_TRANSFORM_TIMEOUT", DEFAULT_TRANSFORM_TIMEOUT_SEC
),
max_output_bytes=_env_int(
"OPENCLAW_TRANSFORM_MAX_OUTPUT", DEFAULT_MAX_OUTPUT_BYTES
),
max_transforms_per_request=_env_int(
"OPENCLAW_TRANSFORM_MAX_PER_REQUEST", DEFAULT_MAX_TRANSFORMS_PER_REQUEST
),
)
# ---------------------------------------------------------------------------
# Transform result
# ---------------------------------------------------------------------------
class TransformStatus(str, Enum):
SUCCESS = "success"
ERROR = "error"
TIMEOUT = "timeout"
DENIED = "denied"
SKIPPED = "skipped"
@dataclass
class TransformResult:
"""Result of a single transform execution."""
transform_id: str
status: str # TransformStatus.value
output: Optional[Dict[str, Any]] = None
error: str = ""
duration_ms: float = 0.0
output_bytes: int = 0
audit: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"transform_id": self.transform_id,
"status": self.status,
"duration_ms": round(self.duration_ms, 2),
"output_bytes": self.output_bytes,
}
if self.output is not None:
d["output"] = self.output
if self.error:
d["error"] = self.error
if self.audit:
d["audit"] = self.audit
return d
# ---------------------------------------------------------------------------
# Transform registry (trusted modules)
# ---------------------------------------------------------------------------
@dataclass
class TrustedTransform:
"""A registered, integrity-pinned transform module."""
id: str
label: str
module_path: str # Absolute path to .py module
sha256: str # Integrity hash of the module file
description: str = ""
trusted_source: str = "" # Who published this transform
registered_at: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
class TransformRegistryError(Exception):
"""Error in transform registry operations."""
pass
class TransformRegistry:
"""
Manages trusted transform modules with integrity pinning.
Transforms can only be loaded from explicitly trusted directories.
Each module is pinned by its SHA256 hash at registration time.
"""
def __init__(self, state_dir: str, trusted_dirs: Optional[List[str]] = None):
self._state_dir = state_dir
self._registry_dir = os.path.join(state_dir, "transforms")
self._index_path = os.path.join(self._registry_dir, "registry.json")
self._transforms: Dict[str, TrustedTransform] = {}
# Trusted directories where transform modules can live
self._trusted_dirs: Set[str] = set()
if trusted_dirs:
for d in trusted_dirs:
resolved = str(Path(d).resolve())
self._trusted_dirs.add(resolved)
os.makedirs(self._registry_dir, exist_ok=True)
self._load()
def _load(self) -> None:
"""Load transform registry from disk."""
if not os.path.exists(self._index_path):
self._transforms = {}
return
try:
with open(self._index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._transforms = {}
for tid, tdata in data.items():
self._transforms[tid] = TrustedTransform(**tdata)
except Exception as e:
logger.error(f"Failed to load transform registry: {e}")
self._transforms = {}
def _save(self) -> None:
"""Persist transform registry to disk."""
try:
data = {k: v.to_dict() for k, v in self._transforms.items()}
tmp_path = self._index_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp_path, self._index_path)
except Exception as e:
logger.error(f"Failed to save transform registry: {e}")
@staticmethod
def _compute_sha256(file_path: str) -> str:
"""Compute SHA256 hash of a file."""
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
h.update(chunk)
return h.hexdigest()
def _is_in_trusted_dir(self, module_path: str) -> bool:
"""Check if a module path is inside a trusted directory."""
resolved = str(Path(module_path).resolve())
for trusted in self._trusted_dirs:
if resolved.startswith(trusted + os.sep) or resolved == trusted:
return True
return False
def register_transform(
self,
transform_id: str,
module_path: str,
*,
label: str = "",
description: str = "",
trusted_source: str = "",
) -> TrustedTransform:
"""
Register a transform module with integrity pinning.
The module must be in a trusted directory and within size limits.
"""
if not is_transforms_enabled():
raise TransformRegistryError(
f"Transforms disabled. Set {_FEATURE_FLAG}=1 to enable."
)
abs_path = str(Path(module_path).resolve())
# Security: must be in trusted directory
if not self._is_in_trusted_dir(abs_path):
raise TransformRegistryError(
f"Module path is not in a trusted directory: {abs_path}"
)
if not os.path.isfile(abs_path):
raise TransformRegistryError(f"Module file not found: {abs_path}")
# Size check
file_size = os.path.getsize(abs_path)
if file_size > MAX_TRANSFORM_MODULE_SIZE_BYTES:
raise TransformRegistryError(
f"Module exceeds size limit ({file_size} > {MAX_TRANSFORM_MODULE_SIZE_BYTES})"
)
# Must be a .py file
if not abs_path.endswith(".py"):
raise TransformRegistryError("Only .py modules are allowed as transforms")
sha256 = self._compute_sha256(abs_path)
transform = TrustedTransform(
id=transform_id,
label=label or transform_id,
module_path=abs_path,
sha256=sha256,
description=description,
trusted_source=trusted_source,
registered_at=time.time(),
)
self._transforms[transform_id] = transform
self._save()
logger.info(f"F42: Registered transform '{transform_id}' from {abs_path}")
return transform
def unregister_transform(self, transform_id: str) -> bool:
"""Remove a transform from the registry."""
if not is_transforms_enabled():
raise TransformRegistryError(
f"Transforms disabled. Set {_FEATURE_FLAG}=1 to enable."
)
if transform_id not in self._transforms:
return False
del self._transforms[transform_id]
self._save()
logger.info(f"F42: Unregistered transform '{transform_id}'")
return True
def get_transform(self, transform_id: str) -> Optional[TrustedTransform]:
"""Get a registered transform by ID."""
return self._transforms.get(transform_id)
def list_transforms(self) -> List[TrustedTransform]:
"""List all registered transforms."""
return list(self._transforms.values())
def verify_integrity(self, transform_id: str) -> bool:
"""Verify that a registered transform's file hasn't been modified."""
transform = self._transforms.get(transform_id)
if not transform:
return False
if not os.path.isfile(transform.module_path):
return False
actual_hash = self._compute_sha256(transform.module_path)
return actual_hash == transform.sha256
# IMPORTANT:
# Keep compatibility exports in this module even after refactor to
# `services.transform_common`; tests and downstream imports still reference
# `services.constrained_transforms` directly.
__all__ = [
"_FEATURE_FLAG",
"DEFAULT_MAX_OUTPUT_BYTES",
"DEFAULT_MAX_TRANSFORMS_PER_REQUEST",
"DEFAULT_TRANSFORM_TIMEOUT_SEC",
"MAX_TRANSFORM_MODULE_SIZE_BYTES",
"TransformLimits",
"TransformRegistry",
"TransformRegistryError",
"TransformResult",
"TransformStatus",
"TrustedTransform",
"TransformExecutor",
"TransformTimeoutError",
"get_transform_executor",
"get_transform_registry",
"is_transforms_enabled",
]
# ---------------------------------------------------------------------------
@@ -536,47 +280,34 @@ class TransformExecutor:
# Module-level convenience
# ---------------------------------------------------------------------------
_registry: Optional[TransformRegistry] = None
_executor: Optional[TransformExecutor] = None
def get_transform_registry() -> TransformRegistry:
"""Get or create the global transform registry."""
global _registry
if _registry is None:
try:
from .state_dir import get_state_dir
state_dir = get_state_dir()
except ImportError:
try:
from services.state_dir import get_state_dir
state_dir = get_state_dir()
except ImportError:
state_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data"
)
# Default trusted directory: pack-local transforms dir
pack_root = Path(__file__).resolve().parent.parent
trusted_dirs = [str(pack_root / "data" / "transforms")]
# Allow additional trusted dirs from env
extra = os.environ.get("OPENCLAW_TRANSFORM_TRUSTED_DIRS", "")
if extra:
for d in extra.split(os.pathsep):
d = d.strip()
if d:
trusted_dirs.append(d)
_registry = TransformRegistry(state_dir, trusted_dirs=trusted_dirs)
return _registry
def get_transform_executor() -> TransformExecutor:
"""Get or create the global transform executor."""
"""
Get or create the global transform executor.
If S35 isolation is enabled (default: True in this hardening wave),
returns a TransformProcessRunner instance.
"""
global _executor
if _executor is None:
_executor = TransformExecutor(get_transform_registry())
# Check for process isolation flag (defaulting to on for S35)
# We can use the same enable flag, or a specific isolation one.
# Let's assume strict isolation is part of the enabling.
# Local import to avoid circular dependency
try:
from .transform_runner import TransformProcessRunner
registry = get_transform_registry()
# We treat TransformProcessRunner as compatible with TransformExecutor interface
_executor = TransformProcessRunner(registry) # type: ignore
except ImportError as e:
logger.warning(
f"S35: Could not import transform_runner ({e}), falling back to thread executor."
)
_executor = TransformExecutor(get_transform_registry())
return _executor
+30 -6
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
"""
S26+: Same-Origin CSRF Protection for Localhost Convenience Mode
@@ -7,16 +9,38 @@ when no admin token is configured (convenience mode).
Purpose: Prevent cross-origin requests from abusing localhost-only admin endpoints.
"""
import json
import logging
import os
from typing import Optional
from aiohttp import web
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover - CI/minimal env path
web = None # type: ignore
# IMPORTANT:
# Keep this module importable even when aiohttp is unavailable (CI/minimal env).
# Tests import `is_same_origin_request` directly; a hard import error here causes
# unrelated auth-hardening suites to fail before skip guards can apply.
class _CompatResponse:
"""Minimal fallback response when aiohttp is unavailable."""
def __init__(self, *, status: int, body: bytes, content_type: str):
self.status = status
self.body = body
self.text = body.decode("utf-8", errors="replace")
self.content_type = content_type
def _json_response(payload: dict, *, status: int):
if web is not None:
return web.json_response(payload, status=status)
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
return _CompatResponse(status=status, body=body, content_type="application/json")
# NOTE FOR CALLERS:
# This module has an import-time aiohttp dependency. If a caller must remain
# importable in minimal test/CI environments without aiohttp, guard the import
# at the caller boundary (see `api/config.py` for the required pattern).
logger = logging.getLogger("ComfyUI-OpenClaw.services.csrf_protection")
@@ -117,7 +141,7 @@ def require_same_origin_if_no_token(
logger.warning(
f"S26+: CSRF protection denied cross-origin request to {request.path}"
)
return web.json_response(
return _json_response(
{
"ok": False,
"error": "csrf_protection",
+136
View File
@@ -0,0 +1,136 @@
"""
R77 Integrity Envelopes.
Provides canonical serialization and integrity verification for persisted state.
"""
import hashlib
import json
import logging
import os
import shutil
import tempfile
from dataclasses import asdict, dataclass
from typing import Any, Dict, Optional, Union
logger = logging.getLogger("ComfyUI-OpenClaw.services.integrity")
@dataclass
class IntegrityEnvelope:
"""
Wrapper for persisted data with integrity metadata.
"""
version: int
data: Dict[str, Any]
hash: str # SHA256 of canonical(data)
algo: str = "sha256"
meta: Optional[Dict[str, Any]] = None
class IntegrityError(Exception):
"""Raised when integrity verification fails."""
pass
def canonical_dumps(data: Any) -> bytes:
"""
Serialize data to canonical JSON (sorted keys, no whitespace).
"""
return json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
def calculate_hash(data: Any, algo: str = "sha256") -> str:
"""
Calculate hash of canonicalized data.
"""
if algo != "sha256":
raise ValueError(f"Unsupported hash algorithm: {algo}")
payload = canonical_dumps(data)
return hashlib.sha256(payload).hexdigest()
def save_verified(path: str, data: Dict[str, Any], version: int = 1) -> None:
"""
Save data wrapped in an integrity envelope.
Atomic write.
"""
data_hash = calculate_hash(data)
envelope = IntegrityEnvelope(
version=version, data=data, hash=data_hash, algo="sha256"
)
# Write to temp string first to Ensure serialization works
try:
content = json.dumps(asdict(envelope), indent=2)
except Exception as e:
logger.error(f"Failed to serialize integrity envelope for {path}: {e}")
raise
# Atomic write
dir_name = os.path.dirname(os.path.abspath(path))
os.makedirs(dir_name, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=dir_name, text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(fd)
# Renaissance-style atomic rename
os.replace(tmp_path, path)
except Exception as e:
logger.error(f"Failed to save verified file {path}: {e}")
if os.path.exists(tmp_path):
os.remove(tmp_path)
raise
def load_verified(
path: str, expected_version: int = 1, migrate: bool = True
) -> Dict[str, Any]:
"""
Load data from an integrity envelope.
If `migrate` is True and the file is valid legacy JSON (no envelope),
it returns the data as-is (caller should save back to upgrade).
Raises IntegrityError if hash mismatch or malformed.
"""
if not os.path.exists(path):
raise FileNotFoundError(f"File not found: {path}")
try:
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
except json.JSONDecodeError as e:
raise IntegrityError(f"Corrupt JSON file {path}: {e}")
# Check if it's an envelope
if isinstance(raw, dict) and "hash" in raw and "data" in raw and "version" in raw:
# Verify integrity
stored_hash = raw["hash"]
stored_data = raw["data"]
computed_hash = calculate_hash(stored_data)
if computed_hash != stored_hash:
raise IntegrityError(f"Integrity check failed for {path} (hash mismatch)")
# Verify version if needed
# We can implement version migration logic here if multiple envelope versions exist
return stored_data
# Legacy Fallback
if migrate:
logger.info(
f"R77: Loaded legacy file {path}, integrity check skipped (pending migration)."
)
# For legacy files, we assume the whole content is the data.
return raw
raise IntegrityError(f"File {path} is not a valid integrity envelope")
+11 -1
View File
@@ -145,6 +145,12 @@ SCHEDULER_ENV_MAPPINGS = {
"skip_missed_intervals": ("OPENCLAW_SCHEDULER_SKIP_MISSED", ""),
}
# IMPORTANT:
# Keep effective-config merge order deterministic.
# Using a set iteration here makes legacy warning assertions flaky because the
# first env key read can vary per process/hash seed.
LLM_KEY_ORDER = tuple(ENV_MAPPINGS.keys())
def _clamp(value: int, min_val: int, max_val: int) -> int:
"""Clamp an integer to a range."""
@@ -271,7 +277,11 @@ def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
effective = {}
sources = {}
for key in ALLOWED_LLM_KEYS:
ordered_keys = list(LLM_KEY_ORDER) + [
k for k in sorted(ALLOWED_LLM_KEYS) if k not in ENV_MAPPINGS
]
for key in ordered_keys:
# 1. Check ENV override
env_val = _get_env_value(key)
if env_val is not None:
+348
View File
@@ -0,0 +1,348 @@
"""
Common types and registry for constrained transforms (S35/F42).
Refactored to avoid circular imports.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
logger = logging.getLogger("ComfyUI-OpenClaw.services.transform_common")
# ---------------------------------------------------------------------------
# Feature gate
# ---------------------------------------------------------------------------
_FEATURE_FLAG = "OPENCLAW_ENABLE_TRANSFORMS"
def is_transforms_enabled() -> bool:
"""Check if constrained transforms are enabled (default: OFF)."""
val = os.environ.get(_FEATURE_FLAG, "").strip().lower()
return val in ("1", "true", "yes", "on")
# ---------------------------------------------------------------------------
# Runtime limits
# ---------------------------------------------------------------------------
DEFAULT_TRANSFORM_TIMEOUT_SEC = 5
DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 # 64KB
DEFAULT_MAX_TRANSFORMS_PER_REQUEST = 5
MAX_TRANSFORM_MODULE_SIZE_BYTES = 50 * 1024 # 50KB — prevent loading huge scripts
@dataclass
class TransformLimits:
"""Runtime limits for transform execution."""
timeout_sec: float = DEFAULT_TRANSFORM_TIMEOUT_SEC
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
max_transforms_per_request: int = DEFAULT_MAX_TRANSFORMS_PER_REQUEST
@classmethod
def from_env(cls) -> "TransformLimits":
"""Load limits from environment variables."""
def _env_int(key: str, default: int) -> int:
try:
return int(os.environ.get(key, str(default)))
except (ValueError, TypeError):
return default
def _env_float(key: str, default: float) -> float:
try:
return float(os.environ.get(key, str(default)))
except (ValueError, TypeError):
return default
return cls(
timeout_sec=_env_float(
"OPENCLAW_TRANSFORM_TIMEOUT", DEFAULT_TRANSFORM_TIMEOUT_SEC
),
max_output_bytes=_env_int(
"OPENCLAW_TRANSFORM_MAX_OUTPUT", DEFAULT_MAX_OUTPUT_BYTES
),
max_transforms_per_request=_env_int(
"OPENCLAW_TRANSFORM_MAX_PER_REQUEST", DEFAULT_MAX_TRANSFORMS_PER_REQUEST
),
)
# ---------------------------------------------------------------------------
# Transform result
# ---------------------------------------------------------------------------
class TransformStatus(str, Enum):
SUCCESS = "success"
ERROR = "error"
TIMEOUT = "timeout"
DENIED = "denied"
SKIPPED = "skipped"
@dataclass
class TransformResult:
"""Result of a single transform execution."""
transform_id: str
status: str # TransformStatus.value
output: Optional[Dict[str, Any]] = None
error: str = ""
duration_ms: float = 0.0
output_bytes: int = 0
audit: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {
"transform_id": self.transform_id,
"status": self.status,
"duration_ms": round(self.duration_ms, 2),
"output_bytes": self.output_bytes,
}
if self.output is not None:
d["output"] = self.output
if self.error:
d["error"] = self.error
if self.audit:
d["audit"] = self.audit
return d
# ---------------------------------------------------------------------------
# Transform registry (trusted modules)
# ---------------------------------------------------------------------------
@dataclass
class TrustedTransform:
"""A registered, integrity-pinned transform module."""
id: str
label: str
module_path: str # Absolute path to .py module
sha256: str # Integrity hash of the module file
description: str = ""
trusted_source: str = "" # Who published this transform
registered_at: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
class TransformRegistryError(Exception):
"""Error in transform registry operations."""
pass
class TransformRegistry:
"""
Manages trusted transform modules with integrity pinning.
Transforms can only be loaded from explicitly trusted directories.
Each module is pinned by its SHA256 hash at registration time.
"""
def __init__(self, state_dir: str, trusted_dirs: Optional[List[str]] = None):
self._state_dir = state_dir
self._registry_dir = os.path.join(state_dir, "transforms")
self._index_path = os.path.join(self._registry_dir, "registry.json")
self._transforms: Dict[str, TrustedTransform] = {}
# Trusted directories where transform modules can live
self._trusted_dirs: Set[str] = set()
if trusted_dirs:
for d in trusted_dirs:
resolved = str(Path(d).resolve())
self._trusted_dirs.add(resolved)
os.makedirs(self._registry_dir, exist_ok=True)
self._load()
def _load(self) -> None:
"""Load transform registry from disk."""
if not os.path.exists(self._index_path):
self._transforms = {}
return
try:
with open(self._index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._transforms = {}
for tid, tdata in data.items():
self._transforms[tid] = TrustedTransform(**tdata)
except Exception as e:
logger.error(f"Failed to load transform registry: {e}")
self._transforms = {}
def _save(self) -> None:
"""Persist transform registry to disk."""
try:
data = {k: v.to_dict() for k, v in self._transforms.items()}
tmp_path = self._index_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp_path, self._index_path)
except Exception as e:
logger.error(f"Failed to save transform registry: {e}")
@staticmethod
def _compute_sha256(file_path: str) -> str:
"""Compute SHA256 hash of a file."""
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
h.update(chunk)
return h.hexdigest()
def _is_in_trusted_dir(self, module_path: str) -> bool:
"""Check if a module path is inside a trusted directory."""
resolved = str(Path(module_path).resolve())
for trusted in self._trusted_dirs:
if resolved.startswith(trusted + os.sep) or resolved == trusted:
return True
return False
def register_transform(
self,
transform_id: str,
module_path: str,
*,
label: str = "",
description: str = "",
trusted_source: str = "",
) -> TrustedTransform:
"""
Register a transform module with integrity pinning.
The module must be in a trusted directory and within size limits.
"""
if not is_transforms_enabled():
raise TransformRegistryError(
f"Transforms disabled. Set {_FEATURE_FLAG}=1 to enable."
)
abs_path = str(Path(module_path).resolve())
# Security: must be in trusted directory
if not self._is_in_trusted_dir(abs_path):
raise TransformRegistryError(
f"Module path is not in a trusted directory: {abs_path}"
)
if not os.path.isfile(abs_path):
raise TransformRegistryError(f"Module file not found: {abs_path}")
# Size check
file_size = os.path.getsize(abs_path)
if file_size > MAX_TRANSFORM_MODULE_SIZE_BYTES:
raise TransformRegistryError(
f"Module exceeds size limit ({file_size} > {MAX_TRANSFORM_MODULE_SIZE_BYTES})"
)
# Must be a .py file
if not abs_path.endswith(".py"):
raise TransformRegistryError("Only .py modules are allowed as transforms")
sha256 = self._compute_sha256(abs_path)
transform = TrustedTransform(
id=transform_id,
label=label or transform_id,
module_path=abs_path,
sha256=sha256,
description=description,
trusted_source=trusted_source,
registered_at=time.time(),
)
self._transforms[transform_id] = transform
self._save()
logger.info(f"F42: Registered transform '{transform_id}' from {abs_path}")
return transform
def unregister_transform(self, transform_id: str) -> bool:
"""Remove a transform from the registry."""
if not is_transforms_enabled():
raise TransformRegistryError(
f"Transforms disabled. Set {_FEATURE_FLAG}=1 to enable."
)
if transform_id not in self._transforms:
return False
del self._transforms[transform_id]
self._save()
logger.info(f"F42: Unregistered transform '{transform_id}'")
return True
def get_transform(self, transform_id: str) -> Optional[TrustedTransform]:
"""Get a registered transform by ID."""
return self._transforms.get(transform_id)
def list_transforms(self) -> List[TrustedTransform]:
"""List all registered transforms."""
return list(self._transforms.values())
def verify_integrity(self, transform_id: str) -> bool:
"""Verify that a registered transform's file hasn't been modified."""
transform = self._transforms.get(transform_id)
if not transform:
return False
if not os.path.isfile(transform.module_path):
return False
actual_hash = self._compute_sha256(transform.module_path)
return actual_hash == transform.sha256
# ---------------------------------------------------------------------------
# Module-level convenience
# ---------------------------------------------------------------------------
_registry: Optional[TransformRegistry] = None
def get_transform_registry() -> TransformRegistry:
"""Get or create the global transform registry."""
global _registry
if _registry is None:
try:
from .state_dir import get_state_dir
state_dir = get_state_dir()
except ImportError:
try:
from services.state_dir import get_state_dir
state_dir = get_state_dir()
except ImportError:
state_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data"
)
# Default trusted directory: pack-local transforms dir
pack_root = Path(__file__).resolve().parent.parent
trusted_dirs = [str(pack_root / "data" / "transforms")]
# Allow additional trusted dirs from env
extra = os.environ.get("OPENCLAW_TRANSFORM_TRUSTED_DIRS", "")
if extra:
for d in extra.split(os.pathsep):
d = d.strip()
if d:
trusted_dirs.append(d)
_registry = TransformRegistry(state_dir, trusted_dirs=trusted_dirs)
return _registry
+183
View File
@@ -0,0 +1,183 @@
"""
S35 Transform Isolation Runner.
Executes transforms in a separate process via `services.transform_worker`.
"""
import json
import logging
import os
import subprocess
import sys
import time
from typing import Any, Dict, Optional
from .transform_common import (
TransformLimits,
TransformRegistry,
TransformResult,
TransformStatus,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.transform_runner")
class TransformProcessRunner:
"""
Executes transforms in an isolated subprocess.
"""
def __init__(
self,
registry: TransformRegistry,
limits: Optional[TransformLimits] = None,
):
self._registry = registry
self._limits = limits or TransformLimits.from_env()
def execute_transform(
self,
transform_id: str,
input_data: Dict[str, Any],
*,
trace_id: str = "",
) -> TransformResult:
"""
Execute a single registered transform in a subprocess.
"""
transform = self._registry.get_transform(transform_id)
if not transform:
return TransformResult(
transform_id=transform_id,
status=TransformStatus.ERROR.value,
error=f"Transform '{transform_id}' not found in registry",
)
# Integrity Check (R77 pre-check)
if not self._registry.verify_integrity(transform_id):
return TransformResult(
transform_id=transform_id,
status=TransformStatus.DENIED.value,
error="Integrity verification failed — module modified",
audit={"reason": "integrity_check_failed", "trace_id": trace_id},
)
start_time = time.monotonic()
# Prepare Worker Command
worker_script = os.path.join(os.path.dirname(__file__), "transform_worker.py")
cmd = [sys.executable, worker_script, transform.module_path]
# Prepare Input
payload = {"input": input_data, "context": {"trace_id": trace_id}}
input_json = json.dumps(payload)
try:
# capability-deny: no environment variable inheritance by default?
# Or minimal env.
env = os.environ.copy()
# Remove sensitive vars if needed?
# S34 Obs: redact env in logs, but here process sees env.
# Best practice: clear sensitive vars.
for key in list(env.keys()):
if "TOKEN" in key or "SECRET" in key or "KEY" in key:
del env[key]
# Subprocess Run
proc = subprocess.run(
cmd,
input=input_json,
capture_output=True,
text=True,
timeout=self._limits.timeout_sec,
env=env,
check=False, # We handle return codes
)
# Execution Time
elapsed_ms = (time.monotonic() - start_time) * 1000
# Handle Return Code
if proc.returncode != 0:
# Script crashed or printed error to stdout/stderr
# Try to parse stdout error first
error_msg = proc.stderr.strip() or "Process crashed with unknown error"
try:
out_json = json.loads(proc.stdout)
if out_json.get("status") == "error":
error_msg = out_json.get("error", error_msg)
except Exception:
pass
return TransformResult(
transform_id=transform_id,
status=TransformStatus.ERROR.value,
error=f"Worker process failed (exit {proc.returncode}): {error_msg}",
duration_ms=elapsed_ms,
audit={"exit_code": proc.returncode, "trace_id": trace_id},
)
# Parse Output
try:
result_json = json.loads(proc.stdout)
except json.JSONDecodeError:
return TransformResult(
transform_id=transform_id,
status=TransformStatus.ERROR.value,
error="Worker returned invalid JSON output",
duration_ms=elapsed_ms,
audit={"raw_stdout": proc.stdout[:1000], "trace_id": trace_id},
)
if result_json.get("status") == "error":
return TransformResult(
transform_id=transform_id,
status=TransformStatus.ERROR.value,
error=result_json.get("error", "Unknown worker error"),
duration_ms=elapsed_ms,
audit={
"trace_id": trace_id,
"traceback": result_json.get("traceback"),
},
)
output_data = result_json.get("output", {})
output_bytes = len(json.dumps(output_data).encode("utf-8"))
if output_bytes > self._limits.max_output_bytes:
return TransformResult(
transform_id=transform_id,
status=TransformStatus.ERROR.value,
error=f"Output size limit exceeded ({output_bytes} > {self._limits.max_output_bytes})",
duration_ms=elapsed_ms,
output_bytes=output_bytes,
audit={"trace_id": trace_id},
)
return TransformResult(
transform_id=transform_id,
status=TransformStatus.SUCCESS.value,
output=output_data,
duration_ms=elapsed_ms,
output_bytes=output_bytes,
audit={"trace_id": trace_id, "isolation": "process"},
)
except subprocess.TimeoutExpired:
elapsed_ms = (time.monotonic() - start_time) * 1000
return TransformResult(
transform_id=transform_id,
status=TransformStatus.TIMEOUT.value,
error=f"Transform timeout exceeded ({self._limits.timeout_sec}s)",
duration_ms=elapsed_ms,
audit={"trace_id": trace_id, "timeout": True},
)
except Exception as e:
elapsed_ms = (time.monotonic() - start_time) * 1000
return TransformResult(
transform_id=transform_id,
status=TransformStatus.ERROR.value,
error=f"Runner exception: {str(e)}",
duration_ms=elapsed_ms,
audit={"trace_id": trace_id},
)
+102
View File
@@ -0,0 +1,102 @@
"""
S35 Transform Isolation Worker.
This script runs in a separate process to execute a transform module.
It reads input JSON from stdin and writes output JSON to stdout.
Usage:
python -m services.transform_worker <module_path>
Protocol:
Input (stdin): JSON object {"input": {...}, "context": {...}}
Output (stdout): JSON object {"status": "success", "output": {...}} or {"status": "error", "error": "..."}
Exit Code: 0 on success/handled error, non-zero on crash.
"""
import argparse
import importlib.util
import json
import os
import socket
import sys
import traceback
from typing import Any, Dict
# S35: Capability Deny-by-Default
# We can't easily drop OS privileges in a cross-platform way without deps,
# but being in a separate process isolates memory and crashes.
# We could monkeypatch network libs here to enforce "no network".
def _deny_network(*args, **kwargs):
raise RuntimeError("Network access denied by S35 transform isolation policy")
# Monkeypatch socket to deny network access
socket.socket = _deny_network
socket.create_connection = _deny_network
# TODO: Monkeypatch http.client, urllib, requests if present?
# Standard library socket blocks most.
def load_module(module_path: str):
"""Load the transform module from path."""
if not os.path.exists(module_path):
raise FileNotFoundError(f"Module not found: {module_path}")
spec = importlib.util.spec_from_file_location("transform_module", module_path)
if not spec or not spec.loader:
raise ImportError(f"Could not load spec for {module_path}")
module = importlib.util.module_from_spec(spec)
sys.modules["transform_module"] = module
spec.loader.exec_module(module)
return module
def main():
parser = argparse.ArgumentParser()
parser.add_argument("module_path", help="Absolute path to the transform module")
args = parser.parse_args()
# Read input payload
try:
input_raw = sys.stdin.read()
if not input_raw:
raise ValueError("Empty input on stdin")
payload = json.loads(input_raw)
data = payload.get("input", {})
except Exception as e:
response = {"status": "error", "error": f"Failed to read input: {e}"}
print(json.dumps(response))
sys.exit(1)
# Execute transform
try:
module = load_module(args.module_path)
if not hasattr(module, "transform"):
raise AttributeError("Module missing 'transform' function")
func = getattr(module, "transform")
if not callable(func):
raise TypeError("'transform' is not callable")
# Run
result = func(data)
# Validate result (JSON serializable?)
# We try to dump it. If it fails, that's an error.
output_payload = {"status": "success", "output": result}
print(json.dumps(output_payload, default=str))
except Exception as e:
# Capture traceback for diagnostics
tb = traceback.format_exc()
response = {"status": "error", "error": str(e), "traceback": tb}
print(json.dumps(response))
sys.exit(0) # Exit 0 because we handled the error gracefully
if __name__ == "__main__":
main()
+20 -6
View File
@@ -11,6 +11,7 @@ Every implementation plan must include the **full test validation procedure** in
- Python 3.10+ (CI uses 3.10/3.11)
- Node.js 18+ (CI uses 20)
- `pre-commit` installed: `python -m pip install pre-commit`
- Backend test deps available in the same interpreter (`numpy`, `pillow`, `aiohttp`)
- Frontend deps installed: `npm install`
## Environment Sanity (Required Guardrails)
@@ -18,11 +19,13 @@ Every implementation plan must include the **full test validation procedure** in
- **Python interpreter must be consistent** for all test commands.
- Verify: `python -c "import sys; print(sys.executable)"`
- If you use conda or venv, ensure the same interpreter runs unit tests and connector tests.
- **Project venv recommended**: use `.venv` when possible to avoid mixed dependencies.
- Create: `python -m venv .venv`
- Activate (bash): `source .venv/bin/activate`
- **Project venv recommended**: use an OS-specific local venv to avoid mixed dependencies.
- Linux/WSL recommended path: `.venv-wsl` (especially when Windows also uses `.venv` in the same repo)
- Other environments: `.venv`
- Create: `python -m venv .venv-wsl` (WSL) or `python -m venv .venv`
- Activate (bash): `source .venv-wsl/bin/activate` (or `.venv/bin/activate`)
- Activate (pwsh): `.\.venv\Scripts\Activate.ps1`
- If tests fail due to missing deps in CI parity, **rerun in `.venv` and record that in the implementation record**.
- If tests fail due to missing deps in CI parity, rerun in the project venv used by scripts and record that in the implementation record.
- **Node version must be 18+** before E2E:
- Verify: `node -v`
- If mismatch in WSL, use the Node 18 path specified below.
@@ -121,8 +124,9 @@ Use these checks before assuming the hook runner is broken:
### Optional: One-Command Full Test Scripts (Fastest)
Use these if you want a single command that runs **all required steps** (detect-secrets, pre-commit, unit tests, E2E). These scripts also handle the most common environment issues (Windows cache locks, Black cache, Node 18).
Both scripts enforce a project-local `.venv` and will bootstrap missing test tooling (`pre-commit`, and `aiohttp` where needed for imports).
If `.venv` exists but is invalid for the current OS (for example created in WSL then reused in Windows), rerun via the script so it can recreate the environment.
Scripts enforce a project-local venv and will bootstrap missing test tooling (`pre-commit`, and `aiohttp` where needed for imports).
On WSL, scripts prefer `.venv-wsl`; on Windows they use `.venv`.
If the selected venv exists but is invalid for the current OS/interpreter, rerun via the script so it can recreate that venv.
Linux script includes an explicit offline fail-fast guard: if dependency bootstrap fails (for example `aiohttp` / `pre-commit` install), it stops with remediation hints instead of continuing with partial state.
- Linux/WSL:
@@ -144,6 +148,16 @@ Then every `git push` will run:
bash scripts/pre_push_checks.sh
```
`scripts/pre_push_checks.sh` is the CI-parity guard and must include all 4 stages:
1) `detect-secrets`
2) all `pre-commit` hooks
3) backend unit tests (`scripts/run_unittests.py --pattern "test_*.py"`)
4) frontend E2E (`npm test`)
IMPORTANT:
- Do not remove stage (3). If pre-push skips backend unit tests, local pushes can pass while GitHub CI fails later.
- Keep dependency bootstrap in this script aligned with `.github/workflows/ci.yml` unit-test dependencies.
1) Detect Secrets (baseline-based)
```bash
+107
View File
@@ -0,0 +1,107 @@
"""
R77 Integrity Tests.
"""
import json
import os
import shutil
import tempfile
import unittest
from services.integrity import (
IntegrityEnvelope,
IntegrityError,
calculate_hash,
canonical_dumps,
load_verified,
save_verified,
)
class TestR77Integrity(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.test_dir)
def test_canonical_serialization(self):
"""Test that JSON is canonicalized (sorted keys, no whitespace)."""
data1 = {"b": 2, "a": 1}
data2 = {"a": 1, "b": 2}
c1 = canonical_dumps(data1)
c2 = canonical_dumps(data2)
self.assertEqual(c1, c2)
# Check format: {"a":1,"b":2}
self.assertEqual(c1, b'{"a":1,"b":2}')
def test_hashing(self):
"""Test hash consistency."""
data = {"foo": "bar"}
h1 = calculate_hash(data)
h2 = calculate_hash(data)
self.assertEqual(h1, h2)
self.assertTrue(len(h1) == 64) # SHA256 hex digest
def test_save_and_load_verified(self):
"""Test saving and loading with integrity envelope."""
path = os.path.join(self.test_dir, "test.json")
data = {"key": "value", "list": [1, 2, 3]}
save_verified(path, data)
# Verify file structure on disk
with open(path, "r") as f:
envelope = json.load(f)
self.assertIn("version", envelope)
self.assertIn("hash", envelope)
self.assertIn("data", envelope)
self.assertEqual(envelope["data"], data)
# Load back
loaded = load_verified(path)
self.assertEqual(loaded, data)
def test_tamper_detection(self):
"""Test that modification of data voids the integrity check."""
path = os.path.join(self.test_dir, "tampered.json")
data = {"secret": "123"}
save_verified(path, data)
# Tamper with the file
with open(path, "r") as f:
envelope = json.load(f)
envelope["data"]["secret"] = "666" # Evil modification
# Hash is NOT updated
with open(path, "w") as f:
json.dump(envelope, f)
# Load should fail
with self.assertRaises(IntegrityError):
load_verified(path)
def test_legacy_migration(self):
"""Test verifying legacy (non-envelope) files."""
path = os.path.join(self.test_dir, "legacy.json")
data = {"old": "data"}
# Save as raw JSON
with open(path, "w") as f:
json.dump(data, f)
# load_verified with migrate=True (default) should succeed
loaded = load_verified(path, migrate=True)
self.assertEqual(loaded, data)
# load_verified with migrate=False should fail
with self.assertRaises(IntegrityError):
load_verified(path, migrate=False)
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -43,6 +43,20 @@ class TestRuntimeConfig(unittest.TestCase):
patcher.start()
self.addCleanup(patcher.stop)
# IMPORTANT:
# runtime_config._get_env_value caches legacy-warning emission in a function
# attribute (`_warned_legacy`). Without resetting it here, test order can
# suppress expected warning logs and produce false negatives in CI.
try:
for mod_name, mod in list(sys.modules.items()):
if not mod_name.endswith("runtime_config"):
continue
fn = getattr(mod, "_get_env_value", None)
if callable(fn):
setattr(fn, "_warned_legacy", set())
except Exception:
pass
for key in [
"MOLTBOT_LLM_PROVIDER",
"MOLTBOT_LLM_MODEL",
+9 -2
View File
@@ -25,9 +25,16 @@ from services.csrf_protection import is_same_origin_request
# classified here, otherwise tests fail.
AUTH_CLASS_BY_ROUTE = {
("GET", "/health"): "public-safe",
("GET", "/logs/tail"): "observability",
# IMPORTANT:
# `/logs/tail` was hardened to admin-only because log payload can expose
# high-sensitivity prompt/runtime context (S34). Keep this auth class in
# sync with api/logs_tail.py to avoid accidental privilege regression.
("GET", "/logs/tail"): "admin",
("GET", "/jobs"): "public-safe",
("GET", "/trace/{prompt_id}"): "observability",
# IMPORTANT:
# Trace endpoint now returns high-sensitivity execution context and is
# intentionally admin-only (S34). Keep as admin to prevent data leakage.
("GET", "/trace/{prompt_id}"): "admin",
("POST", "/webhook"): "webhook-auth",
("POST", "/webhook/submit"): "webhook-auth",
("POST", "/webhook/validate"): "webhook-auth",
+79
View File
@@ -0,0 +1,79 @@
"""
S34 Observability Tiers Tests.
"""
import os
import unittest
from unittest.mock import MagicMock, patch
from services.access_control import require_admin_token, require_observability_access
# Mock helpers
class MockRequest:
def __init__(self, headers=None, remote="1.2.3.4"):
self.headers = headers or {}
self.remote = remote
self.match_info = {}
self.query = {}
class TestS34ObservabilityTiers(unittest.TestCase):
def setUp(self):
self.obs_token = "obs-secret"
self.admin_token = "admin-secret"
os.environ["OPENCLAW_OBSERVABILITY_TOKEN"] = self.obs_token
os.environ["OPENCLAW_ADMIN_TOKEN"] = self.admin_token
def tearDown(self):
if "OPENCLAW_OBSERVABILITY_TOKEN" in os.environ:
del os.environ["OPENCLAW_OBSERVABILITY_TOKEN"]
if "OPENCLAW_ADMIN_TOKEN" in os.environ:
del os.environ["OPENCLAW_ADMIN_TOKEN"]
def test_low_sensitivity_access(self):
"""Health/Config should allow Obs Token."""
req = MockRequest(headers={"X-OpenClaw-Obs-Token": self.obs_token})
allowed, _ = require_observability_access(req)
self.assertTrue(allowed, "Obs token should allow low sensitivity access")
def test_high_sensitivity_denial(self):
"""Trace/Log should DENY Obs Token (require Admin)."""
# Admin check with ONLY obs token should fail
req = MockRequest(headers={"X-OpenClaw-Obs-Token": self.obs_token})
allowed, _ = require_admin_token(req)
self.assertFalse(allowed, "Obs token should NOT pass Admin check")
def test_high_sensitivity_allow(self):
"""Trace/Log should ALLOW Admin Token."""
req = MockRequest(headers={"X-Moltbot-Admin-Token": self.admin_token})
allowed, _ = require_admin_token(req)
self.assertTrue(allowed, "Admin token should pass Admin check")
@patch("api.routes.trace_store")
@patch("api.routes.require_admin_token")
@patch("api.routes.web")
def test_trace_handler_tier_enforcement(
self, mock_web, mock_require_admin, mock_trace_store
):
"""Verify API handler calls the right check."""
import asyncio
from api.routes import trace_handler
# Setup
mock_require_admin.return_value = (False, "Denied")
req = MockRequest()
async def run_test():
await trace_handler(req)
# Execute
asyncio.run(run_test())
# Verify
mock_require_admin.assert_called_once()
if __name__ == "__main__":
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
"""
S35 Transform Isolation Tests.
"""
import json
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
# Add project root to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from services.transform_common import (
TransformLimits,
TransformRegistry,
TransformStatus,
TrustedTransform,
)
from services.transform_runner import TransformProcessRunner
class TestS35TransformIsolation(unittest.TestCase):
def setUp(self):
self.registry = MagicMock(spec=TransformRegistry)
self.limits = TransformLimits(
timeout_sec=2.0, max_output_bytes=1024, max_transforms_per_request=1
)
self.runner = TransformProcessRunner(self.registry, self.limits)
def test_process_execution_success(self):
"""Test successful execution in a subprocess."""
# Create a dummy transform module on disk
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write("def transform(data):\n return {'echo': data['input']}")
module_path = f.name
try:
# Mock registry to return this module
self.registry.get_transform.return_value = TrustedTransform(
id="test_echo",
label="Echo",
module_path=module_path,
sha256="dummy_hash",
)
self.registry.verify_integrity.return_value = True
result = self.runner.execute_transform(
"test_echo", {"input": "hello"}, trace_id="test_s35"
)
self.assertEqual(result.status, TransformStatus.SUCCESS.value)
self.assertEqual(result.output, {"echo": "hello"})
# Ensure it ran in a process? Hard to prove from here without spying on subprocess.
# But the runner uses subprocess.run.
finally:
if os.path.exists(module_path):
os.remove(module_path)
def test_timeout_enforcement(self):
"""Test that slow transforms are killed."""
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
"import time\ndef transform(data):\n time.sleep(5)\n return {}"
)
module_path = f.name
try:
self.registry.get_transform.return_value = TrustedTransform(
id="test_slow",
label="Slow",
module_path=module_path,
sha256="dummy_hash",
)
self.registry.verify_integrity.return_value = True
result = self.runner.execute_transform(
"test_slow", {}, trace_id="test_timeout"
)
self.assertEqual(result.status, TransformStatus.TIMEOUT.value)
self.assertIn("timeout exceeded", result.error)
finally:
if os.path.exists(module_path):
os.remove(module_path)
def test_capability_denial_network(self):
"""Test that network access is denied (by monkeypatch in worker)."""
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
"""
import socket
def transform(data):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('example.com', 80))
return {'status': 'connected'}
except Exception as e:
return {'error': str(e)}
"""
)
module_path = f.name
try:
self.registry.get_transform.return_value = TrustedTransform(
id="test_net", label="Net", module_path=module_path, sha256="dummy_hash"
)
self.registry.verify_integrity.return_value = True
result = self.runner.execute_transform(
"test_net", {}, trace_id="test_net_deny"
)
# The worker monkeypatches socket to raise RuntimeError
# So the transform returns {'error': ...}
# Or crashes if it didn't catch it.
# The script above catches it and returns it.
output = result.output or {}
self.assertEqual(result.status, TransformStatus.SUCCESS.value)
self.assertIn("Network access denied", output.get("error", ""))
finally:
if os.path.exists(module_path):
os.remove(module_path)
if __name__ == "__main__":
unittest.main()
+21 -2
View File
@@ -19,8 +19,27 @@ from unittest.mock import ANY, MagicMock, patch
import services.safe_io as safe_io
import services.webhook_auth as webhook_auth
# Disable logging during tests to avoid noise
logging.disable(logging.CRITICAL)
# IMPORTANT:
# Do NOT disable logging at import time. unittest discovery imports all modules
# before test execution, so import-time logging.disable() leaks globally and
# breaks unrelated assertLogs tests.
_PREV_LOG_DISABLE_LEVEL = None
def setUpModule():
"""Disable noisy logs only while this module's tests are running."""
global _PREV_LOG_DISABLE_LEVEL
_PREV_LOG_DISABLE_LEVEL = logging.root.manager.disable
logging.disable(logging.CRITICAL)
def tearDownModule():
"""Restore global logging state for downstream modules."""
global _PREV_LOG_DISABLE_LEVEL
if _PREV_LOG_DISABLE_LEVEL is None:
logging.disable(logging.NOTSET)
else:
logging.disable(_PREV_LOG_DISABLE_LEVEL)
class TestS36WebhookReplay(unittest.TestCase):