"""内置工具集合。覆盖 L0–L3 风险等级,供编排闭环、司命分级演示与前端调用。 文件类工具(read_file / list_dir / write_file)使用沙箱 base 下的持久子目录 ``workspace``,并通过 ``isolated_workspace=True`` 由 runtime 改写为按角色隔离的 ``workspace/``,使同一角色跨会话/跨 run 文件互通、不同角色互不可见 (workdir 相对 base,受沙箱路径逃逸保护)。多用户上线后隔离主体将切换为真实用户标识。 执行路径: - 沙箱路径(datetime/read_file/list_dir/write_file/shell):``build_request`` 产出 ExecRequest, 由 SandboxClient 执行。参数默认用环境变量传参(避免命令注入);write_file 的 content 因可能 很大(单个 env 受 MAX_ARG_STRLEN≈128KB 限制)改经 stdin 写入——既无大小上限,又不经 shell 解释。 shell 保留直接拼接——本就是任意命令,且经沙箱隔离。 - 直接执行路径(fetch_url):``executor`` 直接发起 HTTP 调用(见 ``web_tools.py``), 不走沙箱;http_client 与密钥由 runtime 注入。 """ from __future__ import annotations from app.application.tool_runtime.web_tools import _fetch_url_executor from app.core.config import settings from app.integrations.sandbox.models import ExecRequest from app.modules.safety.governor import PermissionPolicy from app.modules.tool_runtime.definition import ToolInput, ToolSpec # 沙箱 base 下的持久工作子目录(文件类工具共享,由 runtime 按角色隔离为 workspace/) _WORKSPACE = "workspace" def _need_str(input: ToolInput, key: str) -> str: value = input.get(key) if not isinstance(value, str) or not value.strip(): raise ValueError(f"工具需要非空字符串 '{key}' 输入") return value def _datetime_request(_input: ToolInput) -> ExecRequest: return ExecRequest(argv=["sh", "-c", "date '+%Y-%m-%d %H:%M:%S %Z'"]) def _as_positive_int(value: object, *, default: int) -> int: """把 offset/limit 容错解析为 >=1 的整数;非数字或缺省返回 default。""" try: n = int(value) # str/int/float 均可;bool(True)=1 由 >=1 兜底 except (TypeError, ValueError): return default return n if n >= 1 else default def _read_file_request(input: ToolInput) -> ExecRequest: path = _need_str(input, "path") # 行分页(offset 起始行 1-based / limit 行数):大文件可分段读取,避免整体 cat 后被回显 # 截断、模型拿不到完整内容而反复试错。offset/limit 经 int() 规整为纯数字后拼入 sed 地址 # (数字无注入风险),行范围用单引号包裹(末行 $ 字面、不经 shell 展开),PATH_ARG 仍走 env。 # 缺省 offset/limit 时读全部(行为不变)。 # 使用绝对路径(/bin/sed, /bin/cat):避免 env_clear 后 PATH 失效。 start = _as_positive_int(input.get("offset"), default=1) row_limit = _as_positive_int(input.get("limit"), default=0) if start > 1 or row_limit > 0: range_expr = f"{start},{start + row_limit - 1}" if row_limit > 0 else f"{start},$" return ExecRequest( argv=["sh", "-c", f"sed -n '{range_expr}p' \"$PATH_ARG\""], env={"PATH_ARG": path}, workdir=_WORKSPACE, ) return ExecRequest( argv=["sh", "-c", 'cat -- "$PATH_ARG"'], env={"PATH_ARG": path}, workdir=_WORKSPACE, ) def _list_dir_request(input: ToolInput) -> ExecRequest: path = input.get("path", ".") return ExecRequest( argv=["sh", "-c", 'ls -la -- "$PATH_ARG"'], env={"PATH_ARG": str(path)}, workdir=_WORKSPACE, ) def _write_file_request(input: ToolInput) -> ExecRequest: path = _need_str(input, "path") content = input.get("content", "") # content 经 stdin 写入(cat > file),不经环境变量:单个 env 受 MAX_ARG_STRLEN≈128KB # 限制,大文档经 env 会让 execve 返回 E2BIG、沙箱 ok=False,模型反复重试直到截断; # stdin 既无大小上限、又完全不经过 shell 解释(比 env/argv 更安全,内容里的 `$`、 # 反引号、`%` 等都原样落盘)。PATH_ARG 仍用 env(短、需拼进重定向目标)。 return ExecRequest( argv=["sh", "-c", 'cat > "$PATH_ARG"'], env={"PATH_ARG": path}, stdin=str(content), workdir=_WORKSPACE, ) def _shell_request(input: ToolInput) -> ExecRequest: command = _need_str(input, "command") raw_timeout = input.get("timeout_ms") timeout_ms = ( int(raw_timeout) if isinstance(raw_timeout, int | float) else settings.sandbox_default_timeout_ms ) stdin = input.get("stdin") return ExecRequest( argv=["sh", "-c", command], stdin=stdin if isinstance(stdin, str) else None, timeout_ms=timeout_ms, ) DATETIME = ToolSpec( name="datetime", description="获取当前时间(只读,L0)。", risk_level="L0", permission_policy=PermissionPolicy.allow, build_request=_datetime_request, side_effect=False, ) READ_FILE = ToolSpec( name="read_file", description="读取 workspace 内文件(只读,L1)。", risk_level="L1", permission_policy=PermissionPolicy.allow, build_request=_read_file_request, side_effect=False, isolated_workspace=True, ) LIST_DIR = ToolSpec( name="list_dir", description="列出 workspace 内目录(只读,L1)。", risk_level="L1", permission_policy=PermissionPolicy.allow, build_request=_list_dir_request, side_effect=False, isolated_workspace=True, ) WRITE_FILE = ToolSpec( name="write_file", description="写入 workspace 内文件(有副作用,L2 需确认)。", risk_level="L2", permission_policy=PermissionPolicy.ask, build_request=_write_file_request, side_effect=True, isolated_workspace=True, ) SHELL = ToolSpec( name="shell", description="在沙箱中执行任意 shell 命令(高风险,L3 需确认)。", risk_level="L3", permission_policy=PermissionPolicy.always_ask, build_request=_shell_request, side_effect=True, # 与文件类工具统一默认工作目录(workspace/<角色>):否则 shell 跑在沙箱根目录,cat/ls # 看不到 read_file/write_file 产生的文件,模型会反复试不同路径直到循环截断。shell 本是 # 任意命令,cd 仍可改目录(沙箱路径逃逸保护另兜底),这里只统一默认起点。 isolated_workspace=True, ) FETCH_URL = ToolSpec( name="fetch_url", description="抓取指定 URL 的网页正文(转 markdown)。", risk_level="L2", permission_policy=PermissionPolicy.ask, side_effect=True, audit_required=True, executor=_fetch_url_executor, ) def builtin_specs() -> dict[str, ToolSpec]: """返回内置工具规格字典。""" return { spec.name: spec for spec in [DATETIME, READ_FILE, LIST_DIR, WRITE_FILE, SHELL, FETCH_URL] }