feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter
(编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。
file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。
路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。
search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。
image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。
体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换,
无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘;
vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。
测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表,
用闸门工具把并发变确定性事件,不靠 sleep)。
变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。
注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
This commit is contained in:
AskaEth committed 2026-10-05 09:55:29 +08:00
1 parent ef3dcb699b
commit 3589f4b14f
7 files changed
+842 -15

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
package io
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件给**真正碰文件的工具**实现文件级互斥的目标声明(tool.FileTargeter,机制见
// internal/runtime/tool/fileclaims.go 与 design/15 §2.5.7):占用表要知道「本次调用
// 会碰哪个文件、是读还是写」,而这件事只有工具自己知道 —— file 工具的 path 语义随
// action 变(read/list/search 是读,write 是写),image-crop 的 image 只有「本地路径」
// 这一种输入才碰磁盘。声明集中在这一个文件里(而不是散进各工具文件),是为了让
// 「哪些工具参与互斥」在一处可审计:先前占用表恒空的缺口,正是「机制已就绪、没有
// 任何工具声明」。
//
// 体检结论(plugins/tools/**,逐文件核对 import 与 os/filepath 用法):
// - file:按路径读写/列目录/检索,**唯一会改文件的工具** ⇒ 实现(见下)。
// - image-crop:会按路径读本地源图(URL 与 data URI 不落盘)⇒ 实现(读)。
// - document-read:只读 <DataDir>/extracted 下的 sidecar,**不实现** —— sidecar 的
// 路径布局(子目录名与后缀)是 internal/runtime/documents 的未导出实现,在插件里
// 再拼一遍等于把布局抄成第二份真相,日后布局一改就静默失配;而且该包的写侧是
// 「临时文件 + rename」原子替换,读侧读不到半截文件,这条互斥要挡的撕读风险本就
// 不存在(经**工具**写 sidecar 的唯一路径是 file 工具直指该路径,那是越权目标,
// 本该被沙箱与模型侧拦住,不靠占用表兜)。
// - http / web-fetch / web-search / knowledge:只走网络与内存库,不接任何路径。
// - basic/**(calculator、datetime、json、markdown、random、text 等):纯计算与
// 格式化,整个包没有任何 os/filepath 调用。
//
// 两个实现都必须是**纯函数**(FileTargeter 的约定):只做词法解析(Clean/Abs/roots
// 归属判定),不触盘、不阻塞 —— 声明在每次工具调用**之前**被调用一次。
// file 工具的 action 词表。Execute 的 switch、Parameters 的 enum 与目标声明共用这
// 一份常量,避免声明面与执行面对 action 各写一遍而漂移(声明错了方向,互斥要么漏挡
// 要么误挡)。
const (
actionRead = "read"
actionWrite = "write"
actionList = "list"
actionSearch = "search"
)
// 编译期钉住:会碰文件的工具都实现了声明面。少一个,占用表就少一份覆盖 —— 这正是
// 本文件要防的那类静默缺口,因此用编译期断言而不是运行时检查。
var (
_ tool.FileTargeter = (*fileTool)(nil)
_ tool.FileTargeter = (*cropTool)(nil)
)
// FileTargets 实现 tool.FileTargeter:file 工具本次要碰的就是参数里的 path(search
// 缺 path 时是主 root,与 Execute 的缺省同一口径),操作类别由 action 决定。
//
// 路径取沙箱**解析前的声明值**(f.resolve 的产物:绝对、clean、已确认留在 roots
// 内),而不是模型原样给的相对路径:占用表的词法规范化是按进程当前目录求绝对路径,
// 而工具的真实落点是按 roots 求的,两者不一致就会出现「同一个文件两个键」而放行
// 并行。沙箱会拒绝的路径一律不声明 —— 那种调用根本碰不到任何文件,声明了只会误挡。
func (f *fileTool) FileTargets(args any) []tool.FileTarget {
m, ok := args.(map[string]any)
if !ok {
// 参数不是对象 ⇒ Execute 直接拒绝,碰不到文件。
return nil
}
action := argString(m, "action")
path, ok := f.declaredPath(action, argString(m, "path"))
if !ok {
return nil
}
return []tool.FileTarget{{Op: fileTargetOp(action), Path: path}}
}
// declaredPath 返回本次调用真正会碰的那个路径(词法层),报告是否有一个值得声明的
// 目标。空 path 只有 search 有意义(缺省基准是主 root),其余 action 会被 Execute
// 拒绝;沙箱拒绝的路径也不声明。
func (f *fileTool) declaredPath(action, p string) (string, bool) {
if strings.TrimSpace(p) == "" {
if action != actionSearch {
return "", false
}
base, err := f.searchBase(p)
if err != nil {
return "", false
}
p = base
}
clean, err := f.resolve(p)
if err != nil {
return "", false
}
return clean, true
}
// fileTargetOp 把 action 映射成互斥用的操作类别。**未知/缺失的 action 一律按写处理**
// —— 方向必须选保守的那一侧:宁可多挡一次让模型稍后重试,也不放过两路并发写。执行面
// 对未知 action 会拒绝(schema 的 enum 由分发器校验),但声明面跑在它**之前**,不能
// 假设参数一定合法。
func fileTargetOp(action string) tool.FileOp {
switch action {
case actionRead, actionList, actionSearch:
return tool.FileOpRead
default:
return tool.FileOpWrite
}
}
// FileTargets 实现 tool.FileTargeter:image-crop 只读源图,且只有「本地路径」这一种
// 输入会碰文件系统(http(s) URL 走网络、data URI 是内存里的一段 base64)。路径同样
// 取沙箱解析前的声明值;沙箱会拒的路径不声明。
func (c *cropTool) FileTargets(args any) []tool.FileTarget {
m, ok := args.(map[string]any)
if !ok || c.file == nil {
return nil
}
src := strings.TrimSpace(argString(m, "image"))
if src == "" || cropSourceKindOf(src) != cropSourceFile {
return nil
}
clean, err := c.file.resolve(src)
if err != nil {
return nil
}
return []tool.FileTarget{{Op: tool.FileOpRead, Path: clean}}
}