// Package hooks 的命令执行器. // // 走 execenv.Executor 统一门框 (M1 方案 β), 本地 CLI 映射到 DefaultExecutor // (零开销包装 os/exec), 云端 SaaS 由 platform sandbox.Backend 接管, 本文件 // 对两种后端无差别. // // # 历史 // // 原本是 executor struct + newExecutor 的 OO 封装, 迁移到方案 β 后变成 // 纯函数 runShellHook: hooks 包不再管进程生命周期, 只负责把 HookDef 翻译 // 成 execenv.Spec, 剩下交给 Executor. Manager 和 ShellHandler 分别持有 // execenv.Executor, 两条调用路径各自传入. package hooks import ( "bytes" "context" "encoding/json" "fmt" "runtime" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // runShellHook 执行单个 shell hook 命令. // // 执行流程: // 1. 用 context.WithTimeout 设置超时 (默认 30 秒) // 2. 通过 shell 执行命令 (Linux/Mac 用 /bin/sh, Windows 用 cmd) // 3. 注入环境变量 (按 PluginDir 分流, 见下) // 4. 捕获 stdout 和 stderr // 5. 如果 stdout 是有效 JSON, 解析到 JSONOutput // // 设计决策: // - 用 shell 执行而非直接 exec, 因为 hook 命令可能包含管道, 重定向等 // - 超时后通过 context 取消, kill 子进程 (DefaultExecutor 直接映射, 云端 // 后端按自己的取消语义实现) // // Env 策略分流 (L511 衍生修复, 2026-04-15): // // 精妙之处 (CLEVER): 按 PluginDir 是否为空区分两类 hook, 走不同 Class + env 策略. // // - PluginDir != "" → plugin-owned hook: 零信任, 只透传 execenv 白名单 // (PATH/HOME/LANG/LC_ALL), 防止第三方 plugin 作者的 hook 脚本读到宿主 // 进程的 ANTHROPIC_API_KEY / AWS_SECRET_ACCESS_KEY 等敏感 env. 对齐 L511 // (MCP stdio subprocess) 和 plugin tool env 隔离, 威胁模型一致: "第三方 // 代码在 Flyto 进程权限下运行, 不应隐式继承宿主秘密". 走 ClassPluginHook. // // - PluginDir == "" → user-configured hook (来自 ~/.flyto/settings.json // 或 Manager.Register 直接调用): 保持全量继承 os.Environ(). 威胁模型上 // 这是"用户在自己的机器上写 shell 脚本读自己的 env", 不是特权提升. 对齐 // Git hooks (core.hooksPath) / systemd Exec= / npm pre-scripts 的默认 // 语义: "用户对自己的 hook 目录负责". 强行套白名单会让存量 git push / // curl -H "Authorization: Bearer $TOKEN" / ssh-add 类用户 hook 全部崩 // 溃, 爆炸半径远大于威胁模型收益, 是安全剧场. 走 ClassUserHook. // // 替代方案 (均被否决): // // 1. <原方案: 所有 hook 都 cmd.Env = mergeEnv(os.Environ(), env)> -- 第三方 // plugin 可偷密钥, 见 L511 audit. // 2. 所有 hook 都套白名单, 新增 HookDef.Env 字段作逃生口 -- 存量用户 hook // 一夜 breaking change, 没有宽限期. // 3. 按 HookDef.Source 区分 ("" vs plugin-name) -- PluginDir 是更强信号, // 因为引擎内部从不设 PluginDir, 而 Source 未来可能被更多来源污染 // (HTTP API / SaaS 多租户的 "tenantID:pluginName" 前缀). // // Follow-up (非阻塞): 未来给 plugin-owned hook 加 HookDef.Env 字段, 让 plugin // 作者在 plugin.json 里显式声明需要的 env (走 execenv.ExpandEnvMap 做 ${VAR} // 展开), 对齐 mcp_servers[].env 和 tools[].env 的逃生口模式. 这是朝"手机应用 // 式权限清单"产品愿景迈进的一步, 见 project_plugin_permissions_ui memory. func runShellHook(ctx context.Context, exec execenv.Executor, def HookDef, env map[string]string) *HookResult { result := &HookResult{ Command: def.Command, } // 设置超时 timeout := def.EffectiveTimeout() execCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() start := time.Now() // 构建 shell 命令的 Path / Args 分裂形式, 喂给 execenv.Spec. // Linux/Mac: /bin/sh -c ; Windows: cmd /C . var path string var args []string if runtime.GOOS == "windows" { path = "cmd" args = []string{"/C", def.Command} } else { path = "/bin/sh" args = []string{"-c", def.Command} } // 按 PluginDir 分流构造 Env + Class (见函数 doc Env 策略分流段落). var class execenv.Class var specEnv map[string]string if def.PluginDir != "" { // Plugin-owned 路径: 零信任, 白名单 env + FLYTO_PLUGIN_ROOT + hook 专用 env. // // 精妙之处 (CLEVER): FLYTO_PLUGIN_ROOT 从 def.PluginDir 读, 不走 os.Setenv -- // 不同插件注册的 hook 可能有不同的 PluginDir, 全局变量无法区分. // 内嵌在 HookDef 里, 每次执行用正确的路径, 多插件场景下互不干扰. // // MinimalEnvMap 的 extra 参数会覆盖白名单同名项, 所以调用方传入的 hook 专用 // env 和 FLYTO_PLUGIN_ROOT 都会正确注入. if env == nil { env = map[string]string{} } env["FLYTO_PLUGIN_ROOT"] = def.PluginDir specEnv = execenv.MinimalEnvMap(env) class = execenv.ClassPluginHook } else { // User-configured 路径: 全量继承 os.Environ() + hook 专用 env 叠加. // 威胁模型与 Git hooks / systemd 一致 (用户对自己机器负责), 不套白名单. specEnv = execenv.FullInheritMap(env) class = execenv.ClassUserHook } // 捕获 stdout 和 stderr -- 通过 Spec.Stdout/Stderr Writer 字段, // 不走 Pipe (hook 是 run-and-collect 语义, 不需要流式处理). var stdout, stderr bytes.Buffer proc := exec.Command(execCtx, execenv.Spec{ Class: class, Path: path, Args: args, Env: specEnv, Stdout: &stdout, Stderr: &stderr, }) // 执行命令 err := proc.Run() result.Duration = time.Since(start) result.Stdout = stdout.String() result.Stderr = stderr.String() if err != nil { // 区分超时错误和其他错误 if execCtx.Err() == context.DeadlineExceeded { result.Error = fmt.Errorf("hook command timed out after %v: %s", timeout, def.Command) result.ExitCode = -1 } else if exitErr, ok := err.(interface{ ExitCode() int }); ok { // 命令执行了但退出码非零. 用 interface 断言而非 *exec.ExitError, // 让云端 backend 返回自己的 ExitError 类型也能正常识别 (只要实现 // ExitCode() int 方法). DefaultExecutor 返回真正的 *exec.ExitError, // sandbox.Backend 未来会返回自己的类型, 都被这个接口覆盖. result.ExitCode = exitErr.ExitCode() } else { // 其他错误(命令不存在等) result.Error = fmt.Errorf("hook command failed: %w", err) result.ExitCode = -1 } } // 尝试从 stdout 解析 JSON 输出 // 原项目用这个机制让 hook 返回结构化数据影响 Agent 行为 result.JSONOutput = tryParseJSON(result.Stdout) return result } // tryParseJSON 尝试将字符串解析为 JSON 对象. // 如果 stdout 不是有效 JSON 或不是对象类型,返回 nil. // 只接受 JSON 对象(不接受数组,字符串等),因为 hook 协议要求返回键值对. func tryParseJSON(s string) map[string]any { s = strings.TrimSpace(s) if s == "" || s[0] != '{' { return nil } var result map[string]any if err := json.Unmarshal([]byte(s), &result); err != nil { return nil } return result }