新增 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(网关给每条在途路挂互斥身份)。
123 lines
5.6 KiB
Go
123 lines
5.6 KiB
Go
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}}
|
||
}
|