// Package execenv 提供 Flyto 引擎派生子进程 (plugin shell tool / MCP stdio // server / 未来其他 subprocess transport) 时的环境变量策略. // // 设计目标 // // 1. 零信任默认: Flyto 主进程的 ANTHROPIC_API_KEY / OPENAI_API_KEY / // AWS_SECRET_ACCESS_KEY 等敏感 env **永远不会** 隐式泄漏到子进程. // 2. 显式透传: 子进程需要的 env 必须在配置里显式声明 (plugin.json 的 // tools[].env / mcp_servers[].env, 或 settings.json 的 mcpServers[].env). // 3. ${VAR} 展开: 配置值里的 ${NAME} 会从宿主 env 查 NAME, 查不到返回 // error (启动失败比静默降级更安全, 避免秘密变成空字符串悄悄吃掉). // // 为什么是 internal // // 此包是 plugin 包和 internal/mcp 包共享的底层 env 策略实现. 两个上层包都 // import 它, 它不 import 任何 Flyto 自有包, 位于 import 图最底层. 放在 // internal/ 的作用是**明确不作为 SDK 公开接口**: 消费者 (如飞驼云仓平台) // 不应直接调用 execenv, 而应通过 plugin 包或 MCP 配置的上层 API 间接受益. // // 关于 lifecycle 隔离 (见 engine.go 的 parsePluginMCPServerKey) // // 本包的 env 白名单对 plugin-owned 和 user-configured 的 MCP server **一视 // 同仁**, 不看 server key 前缀. lifecycle 前缀区分是**正交机制**: 它解决 // "禁用 plugin 时不能误伤用户配置的 server" 的问题, 和 env 策略是两回事. // 反向考虑过给 user-configured 放宽 env (继承 os.Environ), 但结论是双轨制 // 在 Flyto 目标场景 (B2B SaaS, 飞驼云仓等) 下宽松那一轨永远是死代码, 反而 // 引入跨场景行为不一致. 详见设计讨论记录. package execenv import ( "fmt" "maps" "os" ) // allowedOSEnvKeys 是从宿主 OS env 透传到子进程的白名单. // // 白名单 (而非黑名单) 的理由: 黑名单需要枚举所有已知敏感 env 前缀, 未来 // Flyto 新接入模型厂商或云服务时, 新加 FOOBAR_API_KEY 会自动绕过黑名单, // 安全退化为 0. 白名单天然防御"未来未知敏感 env", 是 firejail / bubblewrap / // systemd --private-env 等 Unix 沙盒工具的标准做法. // // 条目含义: // - PATH: 让子进程查找非绝对路径命令 (python / git / sh ...) // - HOME: 让 shell 的 ~ 展开 / git 读 ~/.gitconfig 等基本操作工作 // - LANG: 让子进程 UTF-8 / locale 正常 // - LC_ALL: 同上, locale override // // 扩展原则: 新增白名单项必须**明确论证没有安全含义**, 而不是"某个用户的 // 脚本需要它". 后一类需求应由上层配置 env 字段显式声明. var allowedOSEnvKeys = []string{"PATH", "HOME", "LANG", "LC_ALL"} // MinimalEnv 构造子进程的最小环境变量集合. // // 返回值是 "KEY=VALUE" 的切片, 可直接赋给 exec.Cmd.Env. // // 合并规则 (POSIX last-wins, Go os/exec 文档保证此行为): // 1. 白名单 OS env (存在才透传) // 2. extra 声明的 env 追加, 同名 key **覆盖**白名单 // // 所以调用方可以: // - 覆盖 HOME (比如沙盒到 plugin 目录) // - 清除 PATH (传空字符串) // - 注入自己的 API key // // extra 为 nil 或空时返回纯白名单. // // 内部实现基于 buildMinimalEnv helper, 和 MinimalEnvMap 共享合并规则, // 保证两个函数永远同步演进 (白名单新增项 / extra 合并策略变更同时生效). func MinimalEnv(extra map[string]string) []string { base := buildMinimalEnv(extra) out := make([]string, 0, len(base)) for k, v := range base { out = append(out, k+"="+v) } return out } // MinimalEnvMap 构造子进程的最小环境变量 map, 用于 execenv.Spec.Env 字段. // // 语义等同 MinimalEnv 但返回 map[string]string, 让 Executor caller 可以 // 直接赋给 Spec.Env 字段, 避免手动 split "KEY=VALUE" 字符串. // // 返回值保证 non-nil (即使 extra == nil 或白名单一个都不在宿主 env, 也 // 返回空的 non-nil map). 这让调用方可以安全赋给 Spec.Env 字段, 不会触发 // DefaultExecutor.mapToEnvSlice 的 nil-vs-empty 陷阱 (见 executor.go // 的 Spec.Env 字段注释警告). // // 强制约定: 类 A (白名单) 入口的调用方**永远**走此 helper 构造 Spec.Env, // 永远不自己写 map[string]string{} 或 nil, 让陷阱在产品代码路径上不可达. func MinimalEnvMap(extra map[string]string) map[string]string { return buildMinimalEnv(extra) } // FullInheritMap 构造子进程的全继承环境变量 map, 用于 execenv.Spec.Env 字段. // // 语义: os.Environ() 全量继承 + extra 覆盖同名 key. 返回 map[string]string, // 让类 B (全继承) 入口可以直接赋给 Spec.Env 字段而不触发 nil-vs-empty 陷阱. // // 使用场景: ClassUserHook / ClassEvolve / ClassBash / ClassMemoryGit 等 // "用户对自己机器负责" 的入口, 威胁模型和 Git hooks / systemd Exec= / npm // pre-scripts 一致. 和 MinimalEnvMap 对称, 区别只在 base 是全宿主 env 还是 // 白名单 4 个 key. // // 返回值保证 non-nil. 即使宿主 env 为空且 extra 为 nil, 也返回空的 non-nil // map, 对齐 MinimalEnvMap 的 nil-safety 约定. // // 合并规则 (last-wins, 和 MinimalEnvMap 一致): // 1. os.Environ() 全部条目进 base // 2. extra 追加, 同名 key **覆盖** base // // 陷阱警告: os.Environ() 可能包含重复 key (POSIX 允许). Go 的 os.Environ // 不去重, 按插入顺序返回, last-wins. 这里转 map 时自然取 last, 和 // *exec.Cmd.Env = os.Environ() 的语义一致 (os/exec 也会把 map 构造的 env // 重新 last-wins 处理). // // 强制约定: 类 B (全继承) 入口的调用方**永远**走此 helper 构造 Spec.Env, // 永远不自己写 os.Environ() 切片拼装, 让 Spec.Env 字段的 map 类型契约统一. func FullInheritMap(extra map[string]string) map[string]string { base := map[string]string{} for _, kv := range os.Environ() { // POSIX env 格式: KEY=VALUE. 找第一个 '=' 分隔 (VALUE 可含 '='). // 跳过无 '=' 的畸形条目 (不应出现, 但不 panic 保护产品路径). for i := 0; i < len(kv); i++ { if kv[i] == '=' { base[kv[:i]] = kv[i+1:] break } } } maps.Copy(base, extra) return base } // buildMinimalEnv 是 MinimalEnv 和 MinimalEnvMap 共享的内部合并 helper. // // 返回的 map 包含白名单中存在于宿主 env 的条目 + extra 覆盖. 调用方负责 // 把它转成最终需要的格式 ([]string for *exec.Cmd.Env, map for Spec.Env). // // 抽出此 helper 的理由: 原 MinimalEnv 和新 MinimalEnvMap 的合并规则必须 // 永远同步 (白名单新增项, 两个函数都要看到). 共享 helper 比两份独立实现 // 安全, 避免未来审计时发现 "MinimalEnv 加了 X 但 MinimalEnvMap 漏了". // // 返回的 map 每次都是新建的, caller 修改它不影响后续调用. func buildMinimalEnv(extra map[string]string) map[string]string { base := map[string]string{} for _, key := range allowedOSEnvKeys { if v, ok := os.LookupEnv(key); ok { base[key] = v } } maps.Copy(base, extra) return base } // ExpandEnvMap 对 map 的 value 做 ${VAR} / $VAR 展开, 从宿主 os env 查值. // // 语法: Go 标准 os.Expand (与 shell / Docker Compose / Kubernetes envFrom // 大致一致). ${NAME} 和 $NAME 都支持. 字面 $ 用 $$ 转义. 复杂语法 (默认值 // ${VAR:-x}, 子串 ${VAR:0:3}) 不支持, YAGNI. // // 失败模式: 引用了**未设置**的环境变量 → 立刻返回 error, 不展开成空字符串. // 这是显式选择: 如果用户配置 "API_KEY": "${BRAVE_KEY}" 而 BRAVE_KEY 未 set, // 静默降级为空字符串会让子进程启动但 API 调用 401, debug 困难. 硬错误强迫 // 用户在启动期就修配置. // // 注意: 显式设置为空字符串 (export FOO=) 与未设置不同. 前者合法, 后者 error. // 这依赖 os.LookupEnv 的区分能力, os.Getenv 不能区分. // // in 为 nil 或空时返回 nil, nil. func ExpandEnvMap(in map[string]string) (map[string]string, error) { if len(in) == 0 { return nil, nil } out := make(map[string]string, len(in)) for k, v := range in { expanded, err := expandValue(k, v) if err != nil { return nil, err } out[k] = expanded } return out, nil } // expandValue 对单个配置值做 ${VAR} 展开, 遇到未设置的变量返回 error. // // os.Expand 的 callback 不能返回 error, 所以用闭包捕获 firstErr, 遇到第一个 // 未设置变量就记录, 后续 callback 仍被调用但忽略结果 (os.Expand 不支持中途 // 中断), 展开完毕后检查 firstErr. // // 精妙之处 (CLEVER): $$ 转义. // // Go 的 os.Expand 把 $$ 解析为"名为 $ 的 shell special var", 传给 callback // 的 name 是 "$" (单字符). 我们特判 name=="$" 返回字面 "$", 实现 $$ → $ 的 // POSIX 风格转义 (否则会被当成"引用未设置变量 $"而 error). // // 其他 shell special var ($0..$9, $#, $@, $*, $!, $?, $-) 极少出现在 env // 值里, 不特判, 继续走 error 路径, 强制用户改用明确的 ${NAME} 形式. func expandValue(key, val string) (string, error) { var firstErr error result := os.Expand(val, func(name string) string { if name == "" { return "" } if name == "$" { return "$" } if v, ok := os.LookupEnv(name); ok { return v } if firstErr == nil { firstErr = fmt.Errorf("execenv: env var %q referenced by key %q is not set in host environment", name, key) } return "" }) if firstErr != nil { return "", firstErr } return result, nil }