Files
Cyrene_v2/plugins/tools/io/file_targets.go
T
AskaEth 3589f4b14f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
新增 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(网关给每条在途路挂互斥身份)。
2026-10-05 09:55:29 +08:00

123 lines
5.6 KiB
Go
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.
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}}
}