Files
Cyrene_v2/internal/runtime/exec/terminal_test.go
T
AskaEth 51c1672355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。

- 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。

- 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。

- 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。

- 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。

- 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
2026-10-04 19:15:12 +08:00

398 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package exec
import (
"context"
"errors"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// discardLogger returns a logger that writes nowhere, keeping test output clean.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// requireBin skips the test when name is not on PATH.
func requireBin(t *testing.T, name string) {
t.Helper()
if _, err := exec.LookPath(name); err != nil {
t.Skipf("%s not available: %v", name, err)
}
}
// enabledCfg builds an enabled sandbox config with a single allowlisted command
// and optional allowed roots.
func enabledCfg(command string, roots ...string) config.ExecSandbox {
return config.ExecSandbox{
Enabled: true,
CommandList: []string{command},
AllowedRoots: roots,
}
}
func newEnabledExecutor(t *testing.T, cfg config.ExecSandbox) *TerminalExecutor {
t.Helper()
ex, err := New(cfg, discardLogger())
if err != nil {
t.Fatalf("New: %v", err)
}
return ex
}
func TestTerminal_DefaultDisabled_Refuses(t *testing.T) {
t.Parallel()
ex, err := New(config.ExecSandbox{Enabled: false}, discardLogger())
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
if !errors.Is(err, ErrExecutionDisabled) {
t.Fatalf("err = %v, want ErrExecutionDisabled", err)
}
}
func TestTerminal_NewNilLogger(t *testing.T) {
t.Parallel()
if _, err := New(config.ExecSandbox{}, nil); !errors.Is(err, ErrNilLogger) {
t.Fatalf("err = %v, want ErrNilLogger", err)
}
}
func TestTerminal_NewEnabledNeedsRoot(t *testing.T) {
t.Parallel()
_, err := New(enabledCfg("echo"), discardLogger())
if !errors.Is(err, ErrNoAllowedRoot) {
t.Fatalf("err = %v, want ErrNoAllowedRoot", err)
}
}
func TestTerminal_Allowlist_Allowed(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hello"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 0 {
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
}
if !strings.Contains(res.Stdout, "hello") {
t.Errorf("Stdout = %q, want contain hello", res.Stdout)
}
if res.Truncated {
t.Errorf("Truncated = true, want false")
}
if res.PID <= 0 {
t.Errorf("PID = %d, want > 0", res.PID)
}
if res.StartedAt.IsZero() || res.EndedAt.IsZero() {
t.Errorf("timestamps must be set")
}
}
func TestTerminal_Allowlist_Denied(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
// pwd is allowlisted; echo is not.
ex := newEnabledExecutor(t, enabledCfg("pwd", t.TempDir()))
_, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
if !errors.Is(err, ErrCommandNotAllowed) {
t.Fatalf("err = %v, want ErrCommandNotAllowed", err)
}
}
func TestTerminal_Injection_Rejected(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
cases := []struct {
name string
arg string
}{
{"semicolon", "a;b"},
{"pipe", "a|b"},
{"ampersand", "a&b"},
{"subshell", "$(rm -rf /)"},
{"backtick", "`id`"},
{"redirect-out", ">out"},
{"redirect-in", "<in"},
{"newline", "a\nb"},
{"traversal", "../etc"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", c.arg}})
if !errors.Is(err, ErrCommandInjection) {
t.Errorf("arg %q: err = %v, want ErrCommandInjection", c.arg, err)
}
})
}
}
func TestTerminal_WorkDir_DefaultToRoot(t *testing.T) {
t.Parallel()
// Probe the default working directory without comparing rendered path
// strings. On Windows the command may resolve to Git's MSYS coreutils, which
// reports the Windows cwd it was handed as a POSIX path (the user Temp
// directory comes back as /tmp/...), so a "Stdout contains root" check is not
// portable. A marker file that exists only in the sandbox root, read through
// a relative name, is found only when the process really runs in that root.
const (
probeFile = "cwd-probe.txt"
probeBody = "cwd-probe"
)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, probeFile), []byte(probeBody), 0o600); err != nil {
t.Fatalf("write probe: %v", err)
}
requireBin(t, "cat")
catEx := newEnabledExecutor(t, enabledCfg("cat", root))
res, err := catEx.Execute(context.Background(), ExecRequest{Command: []string{"cat", probeFile}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 0 || res.Stdout != probeBody {
t.Errorf("cat %s with no WorkDir: exit=%d stdout=%q stderr=%q, want %q read from the sandbox root %s",
probeFile, res.ExitCode, res.Stdout, res.Stderr, probeBody, root)
}
// And the default must be the root itself, not a parent or a subdirectory:
// pwd with no WorkDir must print exactly what pwd prints for WorkDir=root.
// Both runs share pwd's own rendering, so the comparison stays platform
// independent.
requireBin(t, "pwd")
pwdEx := newEnabledExecutor(t, enabledCfg("pwd", root))
deflt, err := pwdEx.Execute(context.Background(), ExecRequest{Command: []string{"pwd"}})
if err != nil {
t.Fatalf("Execute pwd (default workdir): %v", err)
}
explicit, err := pwdEx.Execute(context.Background(), ExecRequest{
Command: []string{"pwd"},
WorkDir: root,
})
if err != nil {
t.Fatalf("Execute pwd (WorkDir=%s): %v", root, err)
}
if got, want := strings.TrimSpace(deflt.Stdout), strings.TrimSpace(explicit.Stdout); got != want {
t.Errorf("pwd with no WorkDir = %q, want the sandbox root as pwd renders it with WorkDir=%s: %q", got, root, want)
}
}
func TestTerminal_WorkDir_OutsideRejected(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
_, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"echo", "hi"},
WorkDir: os.TempDir(),
})
if !errors.Is(err, ErrWorkDirOutsideRoot) {
t.Fatalf("err = %v, want ErrWorkDirOutsideRoot", err)
}
}
// TestTerminal_Timeout_Killed 校验超时到点后**子进程真的被杀**,而不是被等到自然结束。
//
// 为什么不能拿「~200ms 的精度」当判据(改动前基线:三进程并行整树 2/9 红,
// `timeout took 3.8202774s, want ~200ms (process not killed)`):那条断言测的是
// 「起进程 → 到点杀进程 → 收尸」的墙钟总和,而宿主重压之下**进程创建**这一段本身就
// 能吃掉整个时限(本切片勘察实测:重压下 cmd.Start 直接返回 context deadline
// exceeded,且已到「启动即超时」的量级),于是耗时断言退化成「宿主此刻有多忙」的
// 代理变量 —— 红行却报「process not killed」,指向一个并不存在的生产缺陷。
//
// 现在按语义分两层断言,墙钟只负责区分「被杀」与「被等到自然结束」:
// - ① 控制组:同一个 sleep 子命令在不设短超时时**自己跑完并以 0 退出**;
// 因此超时组那次拿到的非 nil waitErr 只可能来自「到点被杀」——这是与宿主
// 负载无关的确定性判别,也正是本用例名字要断言的事实。
// - ② 超时组:Timeout 到点 ⇒ ErrTimeout,且耗时远小于子进程的自然存活期
// (判别界 10s ≪ 30s:宿主即便卡到秒级也不会误判成红;而「没被杀」会一路
// 跑到 30s,落界之外必红)。
func TestTerminal_Timeout_Killed(t *testing.T) {
t.Parallel()
requireBin(t, "sleep")
ex := newEnabledExecutor(t, enabledCfg("sleep", t.TempDir()))
const (
// childSleepSeconds 是子进程的自然存活期(秒):必须远大于任何宿主负载噪声,
// 「进程没被杀」才会落进判别界之外。
childSleepSeconds = 30
// killDeadline 是超时时间:远小于自然存活期,到点必须被杀。
killDeadline = 200 * time.Millisecond
// controlTimeout 是控制组的超时:只要求子命令能跑完,故留足余量。
controlTimeout = 30 * time.Second
// killProofBound 是「被杀」与「被等到自然结束」的判别界:耗时超过它,
// 就说明这一回合是等到了子进程自己结束,而不是被超时杀掉。
killProofBound = 10 * time.Second
)
// ① 控制组:确认这个子命令本来就以 0 正常退出,下面的推论才成立。
control, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"sleep", "0.05"},
Timeout: controlTimeout,
})
if err != nil {
t.Fatalf("控制组 Execute: %v(子命令自己都跑不完,超时组的结论便不成立)", err)
}
if control.ExitCode != 0 {
t.Fatalf("控制组 ExitCode = %d, want 0(sleep 正常结束本就该是 0)", control.ExitCode)
}
// ② 超时组:到点必须被杀,绝不等到它自己结束。
start := time.Now()
_, err = ex.Execute(context.Background(), ExecRequest{
Command: []string{"sleep", strconv.Itoa(childSleepSeconds)},
Timeout: killDeadline,
})
elapsed := time.Since(start)
if !errors.Is(err, ErrTimeout) {
t.Fatalf("err = %v, want ErrTimeout", err)
}
if elapsed >= killProofBound {
t.Fatalf("超时回合耗时 %v(判别界 %v,子进程自然存活期 %ds):进程没有被杀,而是被等到了自然结束",
elapsed, killProofBound, childSleepSeconds)
}
}
func TestTerminal_Output_Truncated(t *testing.T) {
t.Parallel()
requireBin(t, "head")
ex := newEnabledExecutor(t, enabledCfg("head", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"head", "-c", "2000000", "/dev/zero"},
MaxOutputBytes: 100,
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !res.Truncated {
t.Errorf("Truncated = false, want true")
}
if len(res.Stdout) != 100 {
t.Errorf("len(Stdout) = %d, want 100", len(res.Stdout))
}
}
func TestTerminal_NonZeroExit_ReportedNotError(t *testing.T) {
t.Parallel()
requireBin(t, "false")
ex := newEnabledExecutor(t, enabledCfg("false", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"false"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 1 {
t.Errorf("ExitCode = %d, want 1", res.ExitCode)
}
}
func TestTerminal_Stdin_Passthrough(t *testing.T) {
t.Parallel()
requireBin(t, "cat")
ex := newEnabledExecutor(t, enabledCfg("cat", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"cat"}, Stdin: []byte("ping")})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.Stdout != "ping" {
t.Errorf("Stdout = %q, want ping", res.Stdout)
}
}
func TestTerminal_Env_Whitelist(t *testing.T) {
t.Parallel()
requireBin(t, "env")
root := t.TempDir()
// No allowlist: any injected variable is refused before launching.
noEnv := newEnabledExecutor(t, enabledCfg("env", root))
_, err := noEnv.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}})
if !errors.Is(err, ErrEnvNotAllowed) {
t.Fatalf("no-allowlist err = %v, want ErrEnvNotAllowed", err)
}
// Allowlisted variable is applied.
cfg := enabledCfg("env", root)
cfg.AllowedEnv = []string{"FOO"}
ex := newEnabledExecutor(t, cfg)
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(res.Stdout, "FOO=bar") {
t.Errorf("Stdout = %q, want contain FOO=bar", res.Stdout)
}
}
// 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()))
_, err := ex.Execute(context.Background(), ExecRequest{})
if !errors.Is(err, ErrEmptyCommand) {
t.Fatalf("err = %v, want ErrEmptyCommand", err)
}
}