175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""内置工具集合。覆盖 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]
|
||
}
|