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:只读 /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}} }