- 真 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 份各撞注入预算的大附件仍可能被拦。
398 lines
13 KiB
Go
398 lines
13 KiB
Go
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)
|
||
}
|
||
}
|