"""Bounded script execution and task-owned evidence for the device-test plugin.""" from __future__ import annotations import asyncio from contextlib import suppress import hashlib import ipaddress import json import mimetypes import os from pathlib import Path import re import shutil import signal import subprocess import sys import time from typing import Any from urllib.parse import parse_qsl, unquote, urlsplit import uuid from mcp.types import CallToolResult, TextContent PACKAGE = Path(__file__).resolve().parent SCRIPTS = PACKAGE / "skill" / "scripts" MAX_FILE_BYTES = 64 * 1024 * 1024 MAX_OUTPUT_BYTES = 4 * 1024 * 1024 SECRET_KEY = re.compile( r"(?:^|_)(?:password|passwd|pwd|secret|token|access_token|api_key|apikey|authorization)$", re.IGNORECASE, ) class InputError(ValueError): """Only constant, public messages belong in this exception.""" def local_file(value: str) -> Path: if ( not value or "://" in value or value.startswith(("\\\\", "//")) or re.search(r"[\x00-\x1f]", value) or not Path(value).is_absolute() ): raise InputError("需要本机文件的绝对路径;不接受 URL 或网络共享。") path = Path(value).resolve() if not path.is_file(): raise InputError("本机输入文件不存在。") return path def unicast_host(value: str) -> str: try: if "%" in value: raise ValueError address = ipaddress.ip_address(value) effective = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None effective = effective or address if ( effective.is_unspecified or effective.is_multicast or str(effective) == "255.255.255.255" ): raise ValueError except ValueError: raise InputError("需要一个明确的单播 IP;不接受主机名、网段或广播地址。") from None return str(address) def operation_budget() -> int: value = os.environ.get("ARTEMIS_DEVICE_TEST_TIMEOUT", "45") if not re.fullmatch(r"[0-9]+", value) or not 10 <= int(value) <= 3300: raise InputError("插件操作时限必须是 10..3300 秒的整数。") return int(value) def check_budget(required: float) -> None: if required > operation_budget(): raise InputError("所需时限超过插件操作预算;请缩短操作或同时调整宿主与插件时限。") def artifact_root() -> Path: value = os.environ.get("ARTEMIS_ARTIFACTS_DIR", "") if not value: raise InputError("缺少 Artemis 注入的任务资料目录;请通过 Artemis 任务调用。") path = Path(value).absolute() if path.resolve() != path or path.is_symlink(): raise InputError("任务资料目录不能通过链接指向其他位置。") path.mkdir(parents=True, exist_ok=True) return path def new_artifact(prefix: str, suffix: str) -> Path: return artifact_root() / f"{prefix}-{uuid.uuid4().hex}{suffix}" def task_evidence(name: str, *, allow_missing: bool = False) -> Path: if ( not name or len(name) > 180 or name != Path(name).name or any(char in name for char in '/\\:*?"<>|') or name in {".", ".."} or name.rstrip(" .") != name or re.search(r"[\x00-\x1f]", name) ): raise InputError("证据必须引用当前任务中的普通附件文件名。") path = artifact_root() / name if path.is_symlink() or path.resolve().parent != artifact_root(): raise InputError("证据文件不存在或超出当前任务资料目录。") if not path.is_file() and not (allow_missing and not path.exists()): raise InputError("证据文件不存在或超出当前任务资料目录。") if path.is_file() and path.stat().st_size > MAX_FILE_BYTES: raise InputError("单份证据不能超过 64 MiB。") return path def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def sanitized(value: Any, secrets: tuple[str, ...] = ()) -> Any: if isinstance(value, dict): return { key: "[REDACTED]" if SECRET_KEY.search(str(key)) else sanitized(item, secrets) for key, item in value.items() } if isinstance(value, list): return [sanitized(item, secrets) for item in value] if not isinstance(value, str): return value for secret in sorted(filter(None, secrets), key=len, reverse=True): if value == secret: return "[REDACTED]" if len(secret) >= 4: value = value.replace(secret, "[REDACTED]") value = re.sub(r"(?i)rtsps?://[^\s\"<>]+", "[RTSP_URL_REDACTED]", value) return re.sub( r"(?i)(password|passwd|pwd|token|secret|api_key|authorization)\s*[:=]\s*[^\s,;]+", r"\1=[REDACTED]", value, ) def rtsp_environment(name: str) -> tuple[dict[str, str], tuple[str, ...]]: if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,99}", name): raise InputError("RTSP 输入必须是环境变量名称。") value = os.environ.get(name, "") config = os.environ.get("ARTEMIS_DEVICE_TEST_ENV_FILE", "") if not value and config: from dotenv import dotenv_values value = dotenv_values(local_file(config), interpolate=False).get(name) or "" try: parsed = urlsplit(value) if ( parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname or parsed.port == 0 or parsed.fragment or re.search(r"[\x00-\x20\x7f]", value) ): raise ValueError except ValueError: raise InputError("指定的 RTSP 环境变量未配置或格式无效;请在本机凭据环境中配置。") from None secrets = tuple( filter( None, ( value, parsed.username, parsed.password, unquote(parsed.password or ""), *(item for _, item in parse_qsl(parsed.query)), ), ) ) return {name: value}, secrets def runner_arguments(command: str, arguments: list[str]) -> list[str]: budget = operation_budget() result = ["--timeout", str(budget)] profile = os.environ.get("ARTEMIS_DEVICE_TEST_PROFILE", "") tool_root = os.environ.get("ARTEMIS_DEVICE_TEST_TOOL_ROOT", "") if profile: result += ["--profile", str(local_file(profile))] if tool_root: root = Path(tool_root) if not root.is_absolute() or not root.is_dir() or tool_root.startswith(("\\\\", "//")): raise InputError("工具目录必须是本机已解压目录的绝对路径。") result += ["--tool-root", str(root.resolve())] # Leave time for the runner to terminate its own child before the outer deadline. result[1] = str(max(1, budget - 3)) return [*result, command, *arguments] def failure( message: str, code: str = "invalid_input", *, execution: str = "not_started", side_effect: str = "none", ) -> CallToolResult: return CallToolResult( isError=True, content=[TextContent(type="text", text=message)], structuredContent={ "test_verdict": "not_evaluated", "artemis": { "version": 1, "execution": execution, "side_effect": side_effect, "retryable": False, "error_code": code, "attachments": [], }, }, ) def attach_file(path: Path) -> dict: if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_FILE_BYTES: raise InputError("附件缺失、使用链接或超过 64 MiB;不能声称已归档。") root = artifact_root() if path.parent.resolve() != root: destination = new_artifact("evidence", path.suffix[:16]) temporary = destination.with_name(destination.name + ".tmp") with path.open("rb") as source, temporary.open("xb") as target: shutil.copyfileobj(source, target) if sha256(path) != sha256(temporary): raise InputError("证据复制后的 SHA-256 不匹配。") temporary.rename(destination) path = destination return { "name": path.name, "media_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream", "sha256": sha256(path), } def receipt( operation: str, data: dict, *, target: str = "local-environment", kind: str = "terminal", execution: str = "completed", side_effect: str = "none", error_code: str = "", files: list[Path] | None = None, ) -> CallToolResult: data = sanitized(data) attachments = [] issues = [] for path in files or []: try: attachments.append(attach_file(path)) except (InputError, OSError): issues.append("某份输出未能回读归档;可能缺失、使用链接或超过 64 MiB。") if issues: data["attachment_issues"] = issues error_code = error_code or "attachment_unavailable" data["test_verdict"] = "not_evaluated" log = new_artifact(operation, ".json") with log.open("x", encoding="utf-8") as stream: json.dump(data, stream, ensure_ascii=False, indent=2) attachments.append(attach_file(log)) target_id = ( target if len(target) <= 256 else "sha256:" + hashlib.sha256(target.encode()).hexdigest() ) data["artemis"] = { "version": 1, "execution": execution, "side_effect": side_effect, "retryable": False, "source": { "kind": kind, "target_id": target_id, "label": operation, "captured_at": time.time(), }, "attachments": attachments, } if error_code: data["artemis"]["error_code"] = error_code text = json.dumps(data, ensure_ascii=False) if len(text) > 16000: text = text[:15000] + "\n结果较长;完整结构与任务 JSON 附件保留原始字段。" return CallToolResult( isError=bool(error_code), content=[TextContent(type="text", text=text)], structuredContent=data, ) async def stop_process(process: asyncio.subprocess.Process) -> None: """Reap the worker and its fixed tool descendants before returning from cancellation.""" if os.name == "nt": command = str( Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "taskkill.exe" ) killer = await asyncio.create_subprocess_exec( command, "/PID", str(process.pid), "/T", "/F", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW, ) try: await asyncio.wait_for(killer.wait(), 5) except TimeoutError: killer.kill() await killer.wait() else: with suppress(ProcessLookupError): os.killpg(process.pid, signal.SIGKILL) if process.returncode is None: with suppress(ProcessLookupError): process.kill() await process.wait() async def read_output(process: asyncio.subprocess.Process) -> bytes: chunks = [] size = 0 while chunk := await process.stdout.read(65536): size += len(chunk) if size > MAX_OUTPUT_BYTES: raise InputError("工具输出超过 4 MiB,已停止;部分输出不作为有效观测。") chunks.append(chunk) await process.wait() return b"".join(chunks) async def run_cli( script: str, arguments: list[str], *, operation: str, target: str = "local-environment", kind: str = "terminal", write: bool = False, files: list[Path] | None = None, env: dict[str, str] | None = None, secrets: tuple[str, ...] = (), timeout: float | None = None, ) -> CallToolResult: artifact_root() budget = timeout or operation_budget() native_scripts = {"network_inventory.py", "dependency_runner.py"} if script not in {"tool_runner.py", "device_adapter.py", "evidence.py", *native_scripts}: raise InputError("不支持的脚本入口。") script_path = (PACKAGE if script in native_scripts else SCRIPTS) / script child_env = os.environ.copy() for name in ("FFREPORT", "PYTHONPATH", "PYTHONHOME"): child_env.pop(name, None) child_env.update(env or {}) options = ( {"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True} ) try: process = await asyncio.create_subprocess_exec( sys.executable, "-I", "-B", str(script_path), *arguments, cwd=str(PACKAGE), env=child_env, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, **options, ) except OSError: return failure("固定脚本无法启动;未执行设备动作。", "worker_launch_failed") try: output = await asyncio.wait_for(read_output(process), budget) except (TimeoutError, InputError) as exc: await stop_process(process) return receipt( operation, {"execution_status": "timed_out" if isinstance(exc, TimeoutError) else "output_limit"}, target=target, kind=kind, execution="partial", side_effect="possible" if write else "none", error_code="worker_timeout" if isinstance(exc, TimeoutError) else "output_limit", files=files, ) except asyncio.CancelledError: await asyncio.shield(stop_process(process)) raise except OSError: await stop_process(process) return failure( "工具输出通道中断;执行结果待核对,不能自动重试。", "worker_output_interrupted", execution="unknown", side_effect="possible" if write else "none", ) try: decoded = output.decode("utf-8") try: result = json.loads(decoded) except ValueError: # V2 discovery emits one JSON event per line, rather than one envelope. result = {"events": [json.loads(line) for line in decoded.splitlines() if line.strip()]} if not isinstance(result, dict): raise ValueError except (UnicodeError, ValueError): return receipt( operation, {"returncode": process.returncode, "execution_status": "invalid_output_suppressed"}, target=target, kind=kind, execution="unknown" if write else "completed", side_effect="possible" if write else "none", error_code="invalid_worker_output", files=files, ) result = sanitized(result, secrets) if script == "evidence.py": result.pop("output", None) result["output_location"] = "artemis_task_attachments" status = result.get("execution_status", "") not_started = status in { "invalid_arguments", "tool_missing", "configuration_error", "launch_failed", } attempted = bool( result.get("write_executed") or result.get("write_may_have_started") or result.get("may_have_started") ) verified_mismatch = ( script == "device_adapter.py" and arguments[0] == "verify-version" and result.get("status") in {"FAIL", "INCOMPLETE"} ) error = process.returncode != 0 and not verified_mismatch execution = ( "not_started" if not_started else "unknown" if status in {"cleanup_unconfirmed", "unknown"} else "partial" if status in {"timed_out", "output_limit"} or (error and attempted) else "completed" ) side_effect = ( "confirmed" if write and result.get("write_executed") else "possible" if write and attempted else "none" ) if write and script != "device_adapter.py" and not not_started: side_effect = "possible" if error else "confirmed" return receipt( operation, {"returncode": process.returncode, "tool_result": result, "stderr_suppressed": True}, target=target, kind=kind, execution=execution, side_effect=side_effect, error_code=str(result.get("error_code") or "tool_execution_failed") if error else "", files=files, )