diff --git a/internal/runtime/exec/terminal.go b/internal/runtime/exec/terminal.go index bafdb0f..f5c1134 100644 --- a/internal/runtime/exec/terminal.go +++ b/internal/runtime/exec/terminal.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "os/exec" + "runtime" "sort" "strings" "sync" @@ -192,14 +193,75 @@ func (t *TerminalExecutor) resolveWorkdir(workdir string) (string, error) { return abs, nil } -// buildEnv returns the child environment: the base process environment plus any -// allowlisted overrides from req.Env. Names outside the allowlist are rejected. -func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) { - base := os.Environ() - if len(extra) == 0 { - return base, nil +// terminalEnvAllowlist 返回运行终端子进程所必需的最小宿主环境变量键集合。 +// +// 为什么是这些(审计口径):终端命令可由模型/插件触发,若继承 os.Environ() 全集, +// 子进程就能读到宿主的 API key、token、DB 口令等一切凭据。但子进程又必须拿到一 +// 撮由操作系统/运行时提供的定位与查找变量,否则连可执行文件都解析不了。这里只保 +// 留「去哪找可执行文件、临时目录在哪、用户目录在哪、常规系统信息」这类不含凭据的 +// 键: +// - Windows:PATH/PATHEXT 定位可执行文件;SYSTEMROOT/WINDIR/COMSPEC 是 cmd 与 +// 系统 DLL 加载的前提;TEMP/TMP 是临时目录;USERPROFILE/HOMEDRIVE/HOMEPATH/ +// APPDATA/LOCALAPPDATA 是用户目录;NUMBER_OF_PROCESSORS/OS/ +// PROCESSOR_ARCHITECTURE 是常规系统信息(部分程序依赖)。 +// - Unix:PATH 定位可执行文件;HOME/TMPDIR 是用户与临时目录;USER/SHELL/LANG/ +// LC_ALL 是常规终端身份与本地化变量。 +// +// 其余变量(如 *_API_KEY、*_TOKEN、DB_*、JWT_*)一律不透传;调用方确需某个键时, +// 由 cfg.AllowedEnv 显式放行并经 req.Env 覆盖(见 envAllowed)。 +func terminalEnvAllowlist() []string { + if runtime.GOOS == "windows" { + windowsKeys := []string{ + "PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", + "TEMP", "TMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", + "APPDATA", "LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "OS", + "PROCESSOR_ARCHITECTURE", + } + return windowsKeys } + unixKeys := []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "USER", "SHELL"} + return unixKeys +} +// envKeyEqual 比较两个环境变量名是否指向同一变量:Windows 的名字大小写不敏感 +// (宿主常写成 Path,不能因此漏掉 PATH),Unix 敏感。 +func envKeyEqual(a, b string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} + +// envKeyIn 判断 name 是否属于 keys(按平台口径比较)。 +func envKeyIn(name string, keys []string) bool { + for _, k := range keys { + if envKeyEqual(k, name) { + return true + } + } + return false +} + +// envKeyOf 返回 m 中与 name 等价的键的原始拼写,用于覆盖时避免同名重复键。 +func envKeyOf(m map[string]string, name string) (string, bool) { + if _, ok := m[name]; ok { + return name, true + } + if runtime.GOOS != "windows" { + return "", false + } + for k := range m { + if strings.EqualFold(k, name) { + return k, true + } + } + return "", false +} + +// buildEnv 组装子进程环境:只透传运行终端所必需的最小宿主键集合 +// (terminalEnvAllowlist),再叠加 req.Env 中经 envAllowed 显式放行的覆盖值。 +// 宿主其余变量(含密钥)一律不透传;req.Env 里非白名单的键直接拒绝。 +func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) { overlays := make(map[string]string, len(extra)) for k, v := range extra { if !t.envAllowed(k) { @@ -208,29 +270,28 @@ func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) { overlays[k] = v } - out := make([]string, 0, len(base)+len(overlays)) - seen := make(map[string]struct{}, len(base)+len(overlays)) - for _, e := range base { - k, _, _ := strings.Cut(e, "=") - if v, ok := overlays[k]; ok { - out = append(out, k+"="+v) - delete(overlays, k) - } else { - out = append(out, e) + allow := terminalEnvAllowlist() + kept := make(map[string]string) + for _, e := range os.Environ() { + k, v, ok := strings.Cut(e, "=") + if !ok || k == "" || !envKeyIn(k, allow) { + continue } - seen[k] = struct{}{} + kept[k] = v } - rest := make([]string, 0, len(overlays)) - for k := range overlays { - if _, ok := seen[k]; !ok { - rest = append(rest, k) + for k, v := range overlays { + if existing, ok := envKeyOf(kept, k); ok { + delete(kept, existing) } + kept[k] = v } - sort.Strings(rest) - for _, k := range rest { - out = append(out, k+"="+overlays[k]) + + out := make([]string, 0, len(kept)) + for k, v := range kept { + out = append(out, k+"="+v) } + sort.Strings(out) return out, nil } diff --git a/internal/runtime/exec/terminal_test.go b/internal/runtime/exec/terminal_test.go index c7eeccf..4c13779 100644 --- a/internal/runtime/exec/terminal_test.go +++ b/internal/runtime/exec/terminal_test.go @@ -296,6 +296,52 @@ func TestTerminal_Env_Whitelist(t *testing.T) { } } +// TestTerminal_Env_HostNotInherited 验证子进程不再继承宿主环境变量全集:宿主里 +// 任意非白名单变量(如真实的 API key)都不会泄漏给沙箱命令;同时运行终端所必需的 +// 基础键(PATH)仍在,否则命令无法启动。 +// 注意:t.Setenv 与 t.Parallel 互斥,本用例不得并行。 +func TestTerminal_Env_HostNotInherited(t *testing.T) { + requireBin(t, "env") + + const ( + secretName = "CYRENE_TEST_SECRET_XYZ" + secretValue = "s3cr3t-do-not-leak" + ) + t.Setenv(secretName, secretValue) + + ex := newEnabledExecutor(t, enabledCfg("env", t.TempDir())) + res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"env"}}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0; stderr=%q", res.ExitCode, res.Stderr) + } + + if got := childEnvValue(res.Stdout, secretName); got != "" { + t.Errorf("子进程读到了非白名单宿主变量 %s=%q,宿主全集仍被透传", secretName, got) + } + if got := childEnvValue(res.Stdout, "PATH"); got == "" { + t.Errorf("子进程 PATH 为空,终端命令将无法解析可执行文件;stdout=%q", res.Stdout) + } +} + +// childEnvValue 从 `env` 命令的输出里取 key 的值,未找到返回空串。键名比较走平台 +// 口径(Windows 大小写不敏感),避免把宿主的 `Path` 拼写误判成缺失。 +func childEnvValue(stdout, key string) string { + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimRight(line, "\r") + k, v, ok := strings.Cut(line, "=") + if !ok || k == "" { + continue + } + if envKeyEqual(k, key) { + return v + } + } + return "" +} + func TestTerminal_EmptyCommand(t *testing.T) { t.Parallel() ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))