fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 真 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 份各撞注入预算的大附件仍可能被拦。
This commit is contained in:
AskaEth committed 2026-10-04 19:15:12 +08:00
1 parent d37d4a899b
commit 51c1672355
10 files changed
+577 -48

No files matched your search

+35 -5
View File
@@ -1,11 +1,24 @@
package config
// GuardrailConfig 承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。
// 本文件承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。
//
// 敏感信息阻断档与长度上限是代码内定的默认策略(见组合根的
// buildGuardrailPipeline),不对外开放配置:它们是安全默认值,不该被
// 一次误配置关掉。这里只放内容策略词表 —— 它取决于部署方的场景,
// 项目不内置任何词表。
// 敏感信息阻断档是代码内定的安全默认值(见组合根的 buildGuardrailPipeline),
// 不对外开放配置:它不该被一次误配置关掉。这里放的是两项**与部署环境相关**的
// 取值 —— 内容策略词表,以及请求侧字符上限的显式覆盖。
//
// 请求侧字符上限为什么必须能配:它的默认值由**推导出的上下文预算**派生
// (预算被抬高到 1M 窗口能吃的量级后,硬编码的 256 Ki 字符反而成了瓶颈,
// 会把「分享大文件」整条主线打成 guardrail_input 结束,见
// cmd/cyrene/wire_guardrail.go 的说明)。派生值已经能自适应窗口,本键只是
// 给需要收紧或放宽的部署留一个显式落点。
// GuardrailMaxInputCharsEnv 是请求侧字符上限的配置键名。
//
// 它必须与 GuardrailConfig.MaxInputChars 的 env 标签**逐字一致**:WARN 文案要能
// 直接点出「改哪个键」,因此键名只能有一处出处(一致性由 guardrail_test.go 钉住)。
const GuardrailMaxInputCharsEnv = "GUARDRAIL_MAX_INPUT_CHARS"
// GuardrailConfig 承载内容护栏里由部署方声明的取值(design/04 §5)。
type GuardrailConfig struct {
// Keywords 是内容策略关键词表(GUARDRAIL_KEYWORDS,逗号分隔)。
//
@@ -13,4 +26,21 @@ type GuardrailConfig struct {
// 只会误伤正常对话,且没有任何部署方能对它的命中负责。只有显式声明过
// 的词才会被拦(大小写不敏感的子串匹配)。
Keywords []string `env:"GUARDRAIL_KEYWORDS"`
// MaxInputChars 是**请求侧**字符上限的显式覆盖(GUARDRAIL_MAX_INPUT_CHARS)。
// 三态:
//
// - 未设置 / 0(默认)= 按推导出的上下文预算派生:
// 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径);
// - > 0 = 显式覆盖(原样使用);
// - < 0 = 非法:记一条 WARN 并回落派生值,绝不静默生效(AGENTS §10)。
//
// 派生态下「文档注入预算 ≤ 输入上限」这条关系不变量恒成立(1M 窗口 ⇒
// 注入 349525 字符 ≤ 上限 699050 字符);显式覆盖把它设得比注入预算小时,
// 组合根记一条 WARN 明说「大附件会被护栏拦下」,而不是等到用户上传大文件
// 时才以一个没有上下文的 input_length_exceeded 表现出来。
//
// 它只动**输入**侧:输出上限(65536)防的是模型输出失控,与上下文预算无关,
// 因此不随本键变化、也不开放配置。
MaxInputChars int `env:"GUARDRAIL_MAX_INPUT_CHARS"`
}
+68
View File
@@ -0,0 +1,68 @@
package config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// 本文件锁定 GUARDRAIL_MAX_INPUT_CHARS 的配置侧契约(切片 G2):键名一致、三态可解。
// 派生与两条 WARN 的落点在组合根(cmd/cyrene/wire_guardrail.go 的
// deriveGuardrailInputLimit),这里只保证「键真的能配上、值真的能进来」。
// TestGuardrailMaxInputCharsEnvMatchesTag 钉住键名与 env 标签**逐字一致**:
// 组合根的 WARN 用 GuardrailMaxInputCharsEnv 点出「改哪个键」,两处一旦漂移,
// 日志就会把部署方指到不存在的键上。
func TestGuardrailMaxInputCharsEnvMatchesTag(t *testing.T) {
t.Parallel()
field, ok := reflect.TypeOf(GuardrailConfig{}).FieldByName("MaxInputChars")
if !ok {
t.Fatal("GuardrailConfig 里没有 MaxInputChars 字段")
}
if got := field.Tag.Get("env"); got != GuardrailMaxInputCharsEnv {
t.Fatalf("MaxInputChars 的 env 标签 = %q, want %q", got, GuardrailMaxInputCharsEnv)
}
}
// TestLoad_GuardrailMaxInputCharsTriState 钉住三态取值真的能配进来:
// 未设置 ⇒ 0(= 按推导预算派生);进程环境或 .env 给出正数 ⇒ 覆盖值;
// 负数 ⇒ **原样**进来(非法判定在组合根,那里负责记 WARN 并回落,不在这一层报错,
// 免得一个笔误让整个进程起不来)。
func TestLoad_GuardrailMaxInputCharsTriState(t *testing.T) {
dir := t.TempDir()
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
unsetenvForTest(t, GuardrailMaxInputCharsEnv)
cfg, err := Load(filepath.Join(dir, "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Guardrail.MaxInputChars != 0 {
t.Fatalf("未配置时 = %d, want 0(0 = 按推导预算派生)", cfg.Guardrail.MaxInputChars)
}
// .env 里写了也算配上(与进程环境同一优先级链)。
envFile := filepath.Join(dir, "test.env")
if err := os.WriteFile(envFile, []byte(GuardrailMaxInputCharsEnv+"=1000\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
cfg, err = Load(envFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Guardrail.MaxInputChars != 1000 {
t.Fatalf(".env 配置时 = %d, want 1000", cfg.Guardrail.MaxInputChars)
}
// 负数原样进来:非法值的处理在组合根(WARN + 回落派生值),这一层不拦。
t.Setenv(GuardrailMaxInputCharsEnv, "-1")
cfg, err = Load(envFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Guardrail.MaxInputChars != -1 {
t.Fatalf("负数配置时 = %d, want -1(原样进来,判定在组合根)", cfg.Guardrail.MaxInputChars)
}
}
+51 -6
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -205,21 +206,65 @@ func TestTerminal_WorkDir_OutsideRejected(t *testing.T) {
}
}
// 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()))
start := time.Now()
_, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"sleep", "5"},
Timeout: 200 * time.Millisecond,
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 := time.Since(start); elapsed > 3*time.Second {
t.Errorf("timeout took %v, want ~200ms (process not killed)", elapsed)
if elapsed >= killProofBound {
t.Fatalf("超时回合耗时 %v(判别界 %v,子进程自然存活期 %ds):进程没有被杀,而是被等到了自然结束",
elapsed, killProofBound, childSleepSeconds)
}
}