Files
hunjing/backend/app/application/tool_runtime/builtins.py
T
2026-08-21 16:00:13 +08:00

175 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""内置工具集合。覆盖 L0–L3 风险等级,供编排闭环、司命分级演示与前端调用。
文件类工具(read_file / list_dir / write_file)使用沙箱 base 下的持久子目录
``workspace``,并通过 ``isolated_workspace=True`` 由 runtime 改写为按角色隔离的
``workspace/<character_id>``,使同一角色跨会话/跨 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/<character_id>)
_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]
}