package builtin // FileWrite 工具 -- 写入文件内容(创建或覆盖). // // 这是 Agent 创建新文件或完全重写文件的能力. // 相比 FileEdit 的精确替换,FileWrite 用于创建全新文件或完整覆盖. // // 特性: // - 自动创建父目录(类似 mkdir -p) // - 创建新文件或覆盖已有文件 // - ConcurrencySafe: false,文件写入操作不可并发 import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/pkg/security" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // FileWriteTool 是文件写入工具. type FileWriteTool struct { fileHistory FileHistoryRecorder // 文件历史(可选,为 nil 时不备份) messageID string // 当前消息 ID(由编排器设置) guard security.SecretGuard // 秘密扫描(可选,为 nil 时不扫描) } // NewFileWriteTool 创建一个 FileWrite 工具实例. func NewFileWriteTool() *FileWriteTool { return &FileWriteTool{} } // NewFileWriteToolWithHistory 创建一个带文件历史的 FileWrite 工具实例. // 升华改进(ELEVATED): 注入文件历史,写入前自动备份已有文件. // 替代方案:不备份(新建文件无所谓,但覆盖已有文件就无法回滚了). func NewFileWriteToolWithHistory(history FileHistoryRecorder) *FileWriteTool { return &FileWriteTool{fileHistory: history} } // NewFileWriteToolWithGuard 创建一个带秘密扫描的 FileWrite 工具实例. // // 升华改进(ELEVATED): 早期方案 只保护 TeamMem 同步路径; // 我们默认对所有路径生效,调用方可通过 guard.ExemptPaths 豁免特定目录. // 替代方案:<只在 memory 写入时扫描> - 否决原因:Agent 可以向任意路径写秘密. func NewFileWriteToolWithGuard(history FileHistoryRecorder, guard security.SecretGuard) *FileWriteTool { return &FileWriteTool{fileHistory: history, guard: guard} } // SetMessageID 设置当前消息 ID(由编排器在每轮开始时调用). func (t *FileWriteTool) SetMessageID(id string) { t.messageID = id } // fileWriteInput 是 FileWrite 工具的输入参数. type fileWriteInput struct { FilePath string `json:"file_path"` Content string `json:"content"` } // Name 返回工具名称. func (t *FileWriteTool) Name() string { return "Write" } // Description 返回工具描述. func (t *FileWriteTool) Description(ctx context.Context) string { return "Writes content to a file on the local filesystem. " + "Creates the file if it does not exist, or overwrites it if it does. " + "Automatically creates parent directories as needed. " + "The file_path parameter must be an absolute path." } // InputSchema 返回工具的 JSON Schema 输入定义. func (t *FileWriteTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "file_path": { "type": "string", "description": "The absolute path to the file to write" }, "content": { "type": "string", "description": "The content to write to the file" } }, "required": ["file_path", "content"] }`) } // Metadata 返回工具元数据. func (t *FileWriteTool) Metadata() tools.Metadata { return tools.Metadata{ ConcurrencySafe: false, ReadOnly: false, Destructive: false, Aliases: []string{"FileWrite"}, SearchHint: "write file create overwrite", PermissionClass: permission.PermClassFile, AuditOperation: "write", } } // Execute 写入文件内容. func (t *FileWriteTool) Execute(ctx context.Context, input json.RawMessage, progress tools.ProgressFunc) (*tools.Result, error) { var params fileWriteInput if err := json.Unmarshal(input, ¶ms); err != nil { return nil, fmt.Errorf("filewrite: invalid input: %w", err) } if params.FilePath == "" { return &tools.Result{ Output: "error: file_path is required", IsError: true, }, nil } // 规范化路径:清理 . 和 .. 分量,防止路径注入 // 升华改进(ELEVATED): 早期方案直接用 params.FilePath,未经 filepath.Clean. // 若调用方传入 "/allowed/dir/../../../etc/evil",filepath.Dir 会计算出 // "/allowed/dir/../../../etc" 然后 MkdirAll 会在 /etc 下创建目录. // filepath.Clean 在此处消除 .. 分量,之后的所有操作都基于规范化路径. // 替代方案:<信任 AI 永远不会传恶意路径> - // 否决:外部工具链,测试代码,未来集成方都可直接调用此函数. params.FilePath = filepath.Clean(params.FilePath) // ── 秘密扫描(写入前拦截)── // 升华改进(ELEVATED): 在任何文件操作之前扫描,包括目录创建和历史备份. // 拦截时机越早,副作用越少(不会产生空目录或不完整的历史记录). // 早期方案只保护 TeamMem 路径;我们默认全路径保护. if t.guard != nil { matches, err := t.guard.Scan(params.FilePath, params.Content) if err == security.ErrContentTooLarge { // 历史包袱(LEGACY): 内容超限时记录警告并放行,而非拒绝. // 理想情况下应该拒绝,但超大文件(日志,数据导出)通常不含 // 人工录入的 key.保守策略会阻碍 Agent 写大文件的合法场景. // 未来改进:提供 OnContentTooLarge 回调让调用方决策. _ = err // 放行,不拦截 } else if err != nil { return &tools.Result{ Output: fmt.Sprintf("error: secret scan failed: %v", err), IsError: true, }, nil } else if len(matches) > 0 { labels := strings.Join(security.MatchLabels(matches), ", ") return &tools.Result{ Output: fmt.Sprintf( "error: secret detected — %s — cannot write to %s\n"+ "Remove the sensitive content and try again.", labels, params.FilePath, ), IsError: true, }, nil } } // ── 文件历史备份 ── // 精妙之处(CLEVER): 在写入前备份.如果文件不存在(新建), // FileHistory 会记录为 OriginalExists=false,回滚时会删除该文件. if t.fileHistory != nil { _ = t.fileHistory.BeforeWrite(params.FilePath, t.messageID) } // 精妙之处(CLEVER): 自动创建父目录链(类似 mkdir -p)--Agent 创建深层路径的文件时 // 不需要先手动 mkdir.这是提升 Agent 自主性的关键细节: // 没有这个,Agent 每次写入新目录下的文件都要先调一次 Bash mkdir. dir := filepath.Dir(params.FilePath) if err := os.MkdirAll(dir, 0755); err != nil { return &tools.Result{ Output: fmt.Sprintf("error creating directories: %v", err), IsError: true, }, nil } // 检查文件是否已存在(用于返回信息) _, err := os.Stat(params.FilePath) isNew := os.IsNotExist(err) // 原子写入:写临时文件 → os.Rename // // 升华改进(ELEVATED): 早期方案 os.WriteFile 直接写目标文件--进程在写入中途被 kill // 或磁盘写满时,文件处于截断/损坏状态,且无法区分"新建失败"和"覆盖失败". // temp + rename 模式保证:要么旧文件完整保留,要么新文件完整落盘,不存在中间态. // 同时权限 0600 而非 0644--写入内容可能含 API key,数据库密码等敏感信息. // 替代方案:<直接 os.WriteFile> - 否决:崩溃/断电时文件损坏,难以排查. // // 精妙之处(CLEVER): 临时文件建在目标文件同目录下(而非 os.TempDir())-- // 跨设备 rename(如 /tmp → /home/user/...)会 fallback 为 copy+delete, // 不再是原子操作.同目录保证 rename 是单次系统调用,真正原子. tmpFile, err := os.CreateTemp(dir, ".flyto-write-*") if err != nil { return &tools.Result{ Output: fmt.Sprintf("error creating temp file: %v", err), IsError: true, }, nil } tmpPath := tmpFile.Name() // 确保临时文件在任何路径下都被清理 defer func() { _ = os.Remove(tmpPath) }() if _, err := tmpFile.WriteString(params.Content); err != nil { _ = tmpFile.Close() return &tools.Result{ Output: fmt.Sprintf("error writing temp file: %v", err), IsError: true, }, nil } if err := tmpFile.Chmod(0600); err != nil { _ = tmpFile.Close() return &tools.Result{ Output: fmt.Sprintf("error setting file permissions: %v", err), IsError: true, }, nil } if err := tmpFile.Close(); err != nil { return &tools.Result{ Output: fmt.Sprintf("error flushing temp file: %v", err), IsError: true, }, nil } if err := os.Rename(tmpPath, params.FilePath); err != nil { return &tools.Result{ Output: fmt.Sprintf("error writing file: %v", err), IsError: true, }, nil } if isNew { return &tools.Result{ Output: fmt.Sprintf("Created %s (%d bytes)", params.FilePath, len(params.Content)), IsError: false, }, nil } return &tools.Result{ Output: fmt.Sprintf("Wrote %s (%d bytes)", params.FilePath, len(params.Content)), IsError: false, }, nil } // ───────────────────────────────────────────────────────────────────── // ToolCapability 协议实现 // ───────────────────────────────────────────────────────────────────── // Capability 声明 FileWrite 工具的安全能力. // 实现 tools.CapabilityProvider 接口. func (t *FileWriteTool) Capability() tools.ToolCapability { return tools.ToolCapability{ DryRun: true, Reversible: true, UndoMethod: "tool", UndoToolName: "Write", AffectedResources: []string{"file"}, } } // DryRun 模拟执行文件写入,返回预览但不实际写入. // 实现 tools.DryRunnable 接口. func (t *FileWriteTool) DryRun(ctx context.Context, input json.RawMessage) (*tools.DryRunResult, error) { var params fileWriteInput if err := json.Unmarshal(input, ¶ms); err != nil { return nil, fmt.Errorf("filewrite: invalid input: %w", err) } _, err := os.Stat(params.FilePath) isNew := os.IsNotExist(err) action := "overwrite" if isNew { action = "create" } return &tools.DryRunResult{ WouldAffect: params.FilePath, Preview: fmt.Sprintf("Would %s %s (%d bytes)", action, params.FilePath, len(params.Content)), EstimatedImpact: map[string]any{ "action": action, "file_path": params.FilePath, "bytes": len(params.Content), }, }, nil } // GenerateUndo 基于写入结果生成撤销信息. // 实现 tools.Reversible 接口. // // 精妙之处(CLEVER): 新建文件的撤销是删除,覆盖文件的撤销是恢复原内容. // 具体的恢复由 FileHistory 系统完成,这里只生成撤销指令描述. func (t *FileWriteTool) GenerateUndo(ctx context.Context, input json.RawMessage, result *tools.Result) (*tools.UndoInfo, error) { var params fileWriteInput if err := json.Unmarshal(input, ¶ms); err != nil { return nil, fmt.Errorf("filewrite: invalid input for undo: %w", err) } return &tools.UndoInfo{ ToolName: "Write", Input: map[string]any{ "file_path": params.FilePath, "content": "[restored from file history backup]", }, Description: fmt.Sprintf("恢复 %s 到写入前的版本", params.FilePath), }, nil }