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

+15 -3
View File
@@ -226,11 +226,23 @@ SUBAGENT_TIMEOUT_SEC=600
# 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。
# 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。
# 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。
# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限,否则大附件会在装配完之后
# 被输入护栏整回合拦死(input_length_exceeded)。改了本键就顺手核对那一项。
# DOCUMENT_MAX_INJECT_CHARS=
# 请求侧字符上限(输入护栏跑在**组装完成的请求**上:系统提示词 + 记忆块 + 整段对话 + 工具结果)。
# 三种取值:
# · 未设置 / 0(默认)= 由推导出的上下文预算派生:max(262144, 推导预算 token 数)
# (1 字符 ≈ 1 token 的保守口径);1M 窗口 ⇒ 预算 699050 token ⇒ 上限 699050 字符,
# 而单份附件注入预算是 349525 字符 ⇒ 关系不变量「注入预算 ≤ 输入上限」成立,
# 「分享大文件」才走得通。小窗口部署下派生的 token 数低于 262144,行为与旧硬编码一致。
# · > 0 = 显式覆盖(原样使用;调小到注入预算之下会让大附件被拦,届时启动日志里有 WARN)
# · < 0 = 非法:记一条 WARN 并回落派生值(绝不静默生效)
# 它只动**输入**侧;输出上限(65536 字符)防的是模型输出失控,与上下文预算无关,不开放配置。
# 敏感信息(API key / bearer token / 密码 / 私钥)为阻断档,同样是代码内定的安全默认值。
# (取值与关系不变量的落点在 cmd/cyrene/wire_guardrail.go)
# GUARDRAIL_MAX_INPUT_CHARS=
# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key /
# bearer token / 密码 / 私钥)为阻断档、请求与响应各有长度上限,这两项是代码
# 内定的安全默认值,不对外开放配置(见 cmd/cyrene/wire_guardrail.go)。
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。
# GUARDRAIL_KEYWORDS=
# ---- 主人身份说明书注入范围(design/11 §0 红线) ----
+1 -1
View File
@@ -185,7 +185,7 @@ func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
}
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline()) {
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) {
if ag.Instructions() != base {
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
}
+3 -3
View File
@@ -387,8 +387,8 @@ func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
//
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
// 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、
// 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。
// 只能在源码层钉住:wire_runtime.go 必须把预算取自 a.contextBudget(envPath),
// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 loop。
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
t.Parallel()
@@ -397,7 +397,7 @@ func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
t.Fatalf("read wire_runtime.go: %v", err)
}
for _, want := range []string{
"a.buildGuardrailPipeline()",
"a.buildGuardrailPipeline(ctxBudget)",
"a.contextBudget(envPath)",
"MaxContextTokens: ctxBudget.Budget.Tokens",
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
+76 -15
View File
@@ -1,12 +1,13 @@
package main
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
import (
"log/slog"
// 组合根的默认护栏策略(design/04 §5)。本条修的就是「护栏是空壳」:
// 此前 `guardrail.New()` 不带任何 Option,管线里一个检查都没有,
// 输入/输出两侧的 guardrail span 每次都只是走过场。
//
// 三条默认策略与它们的依据:
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
)
// 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据:
//
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
@@ -15,19 +16,29 @@ import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
const (
// guardrailMaxInputChars 是请求侧的字符上限。这个检查跑在**组装完成的
// 请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),不是单条用户
// 消息,因此上限必须按「整个上下文」来定:默认压缩预算 32K token
// 折算成字符约 65K(中文)~131K(拉丁),取 262144(256 Ki)字符约为
// 它的 2~4 倍 —— 正常对话(哪怕很长)永远碰不到,只有把几百 KB 的
// 整块文本直接灌进来这种病态输入才会被拦下。
guardrailMaxInputChars = 262144
// guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查
// 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),
// 不是单条用户消息,因此上限按「整个上下文」定。
//
// 为什么是「下限」而不是「定值」(本切片修的真 bug):单份附件的注入预算随
// 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的
// 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束
// (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`),
// 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。
// 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数),
// 取 1 字符 ≈ 1 token 的保守口径。小窗口部署下派生的 token 数本就小于它,
// 输入上限与接线前逐字节一致。
guardrailMinInputChars = 262144
// guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字,
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
// 所以上限必须留足余量。
//
// **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧
// 解耦不该顺手把它也放宽。
guardrailMaxOutputChars = 65536
)
@@ -35,14 +46,19 @@ const (
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
// 断言这个函数的产物,而不必启动整个运行时。
//
// 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量
// 「文档注入预算 ≤ 输入上限」的两端都在这份预算里),而预算的推导只该在
// wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN 记两遍。
// 调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。
//
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
func (a *app) buildGuardrailPipeline() *guardrail.Pipeline {
func (a *app) buildGuardrailPipeline(budget ctxBudget) *guardrail.Pipeline {
opts := []guardrail.Option{
// ① 敏感信息外泄必须拦:阻断档。
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
guardrail.WithLengthLimits(guardrailMaxInputChars, guardrailMaxOutputChars),
guardrail.WithLengthLimits(a.guardrailInputLimit(budget), guardrailMaxOutputChars),
}
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
@@ -52,6 +68,51 @@ func (a *app) buildGuardrailPipeline() *guardrail.Pipeline {
return guardrail.New(opts...)
}
// guardrailInputLimit 返回本次运行生效的请求侧字符上限:
// 配置显式覆盖优先,否则由推导出的上下文预算派生(见 deriveGuardrailInputLimit)。
func (a *app) guardrailInputLimit(budget ctxBudget) int {
override := 0
if a.cfg != nil {
override = a.cfg.Guardrail.MaxInputChars
}
return deriveGuardrailInputLimit(
sourcedWiringLog(a.log), override,
budget.Budget.Tokens, budget.DocumentInjectChars,
)
}
// deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式
// 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。
//
// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数)。字符与
// token 按 1:1 的保守口径换算 —— 上限宁可放宽一点也不该误伤正常上下文,而它仍然
// 只拦「几百 KB 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。
func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars int) int {
derived := guardrailMinInputChars
if budgetTokens > derived {
derived = budgetTokens
}
limit := derived
switch {
case override < 0:
// 非法覆盖:记一条带原始取值的 WARN 并回落派生值(AGENTS §10:不许静默)。
log.Warn("guardrail input limit invalid, falling back to derived",
"env", config.GuardrailMaxInputCharsEnv, "value", override, "fallback", derived)
case override > 0:
limit = override
}
if docInjectChars > limit {
// 关系不变量被破:注入预算比输入上限还大,意味着「能注入的正文」本身就会
// 触发输入护栏 —— 大附件会在装配完之后被整回合拦下,现象是用户看到一个
// 没有上下文的 input_length_exceeded。这里明说一句,不静默。
log.Warn("document inject budget exceeds guardrail input limit; large attachments will be blocked",
"env", config.GuardrailMaxInputCharsEnv, "guardrail_max_input_chars", limit,
"document_inject_chars", docInjectChars,
"hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使注入预算不超过输入上限")
}
return limit
}
// guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS);
// 未配置时返回 nil,管线里就不含关键词检查。
func (a *app) guardrailKeywords() []string {
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定切片 G2 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
//
// - 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径);
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
// - 关系不变量「文档注入预算 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
//
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
const largeDocChars = 400000
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
// 输入上限 = max(下限, 推导预算 token 数) —— 小窗口部署落回下限,大窗口随预算抬高。
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tokens int
want int
}{
{"1M 窗口推导出的预算(699050)⇒ 随之上抬", 699050, 699050},
{"预算正好等于下限 ⇒ 下限", guardrailMinInputChars, guardrailMinInputChars},
{"小预算(600)⇒ 回落下限", 600, guardrailMinInputChars},
{"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars},
{"负预算 ⇒ 下限", -1, guardrailMinInputChars},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0); got != c.want {
t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want)
}
})
}
}
// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记
// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。
func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) {
t.Parallel()
log, buf := capturingLog()
if got := deriveGuardrailInputLimit(log, 1000, 699050, 0); got != 1000 {
t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got)
}
if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 {
t.Fatalf("合法覆盖却记了 %d 条 WARN", n)
}
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, -1, 699050, 0); got != 699050 {
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 699050", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 {
t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
// WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") {
found = true
if rec["env"] != config.GuardrailMaxInputCharsEnv {
t.Fatalf("WARN 没点出配置键: %v", rec)
}
if rec["value"] != float64(-1) || rec["fallback"] != float64(699050) {
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到非法覆盖的 WARN")
}
}
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
// 「文档注入预算 ≤ 输入护栏上限」:对 1M 窗口的推导值成立,且人为把上限设得比
// 注入预算小 ⇒ **记一条 WARN**(说明「大附件会被护栏拦下」),不静默。
func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) {
t.Parallel()
// 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
if budget.DocumentInjectChars != 349525 {
t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)",
budget.DocumentInjectChars)
}
log, buf := capturingLog()
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars)
if budget.DocumentInjectChars > limit {
t.Fatalf("关系不变量被破:注入预算 %d > 输入上限 %d", budget.DocumentInjectChars, limit)
}
if limit != 699050 {
t.Fatalf("1M 窗口下的输入上限 = %d, want 699050(= 推导预算 token 数)", limit)
}
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
}
// 人为破坏:把上限压到注入预算之下 ⇒ 必须有一条 WARN 明说后果。
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars); got != 4096 {
t.Fatalf("收紧后的上限 = %d, want 4096", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 {
t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") {
found = true
if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) ||
rec["guardrail_max_input_chars"] != float64(4096) {
t.Fatalf("WARN 没写清两端取值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到关系不变量被破的 WARN")
}
}
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
type largeAttachmentTurn struct {
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
result *loop.Result
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
modelCalls int
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
assembledChars int
// injectChars 是本回合如实记录的注入字符数(Result.Documents[0].Chars)。
injectChars int
}
// runLargeAttachmentTurn 跑一个回合:1M 窗口的推导预算(注入 349525 字符)+
// 一份 largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时
// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。
//
// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的
// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。
// 唯一被单独钉住的是「窗口 = 1M」。
func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmentTurn {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
if err := os.MkdirAll(uploads, 0o700); err != nil {
t.Fatalf("建上传目录: %v", err)
}
body := strings.Repeat("abcdefghij", largeDocChars/10)
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(body), 0o600); err != nil {
t.Fatalf("写上传件: %v", err)
}
cfg := &config.Config{DataDir: dataDir}
cfg.Guardrail.MaxInputChars = inputLimitOverride
a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
guards := a.buildGuardrailPipeline(budget)
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Guards: guards,
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
DocumentMaxInjectChars: budget.DocumentInjectChars,
OnStream: func(model.StreamEvent) {},
})
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(body))})
res, err := l.Run(context.Background(), "sess-large-doc", []model.Message{msg})
if err != nil {
t.Fatalf("Run: %v", err)
}
turn := largeAttachmentTurn{result: res, modelCalls: len(adapter.reqs)}
if len(res.Documents) > 0 {
turn.injectChars = res.Documents[0].Chars
}
if len(adapter.reqs) > 0 {
req := adapter.reqs[len(adapter.reqs)-1]
for _, m := range req.Messages {
turn.assembledChars += len(loopText(m))
}
}
return turn
}
// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端
// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
//
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
// 抵达模型(adapter 被调用一次)并以正常回答结束。
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
t.Parallel()
turn := runLargeAttachmentTurn(t, 0)
// ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
if turn.result.StopReason == agent.StopReasonGuardrailInput {
t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text)
}
if turn.modelCalls != 1 {
t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
}
// ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数),
// 比旧上限 262144 大,所以上面那条断言不是碰巧成立。
if turn.injectChars != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
turn.injectChars)
}
if turn.assembledChars <= guardrailMinInputChars {
t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」",
turn.assembledChars, guardrailMinInputChars)
}
if !turn.result.Documents[0].Truncated {
t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)")
}
}
// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证:
// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS
// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。
//
// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。
func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) {
t.Parallel()
turn := runLargeAttachmentTurn(t, 1000)
if turn.result.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput)
}
if !strings.Contains(turn.result.Text, "input_length_exceeded") {
t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text)
}
if turn.modelCalls != 0 {
t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls)
}
// 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。
if turn.injectChars != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.injectChars)
}
}
// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化
// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「注入预算 ≤ 上限」
// 不可能靠两边同源凑出来 —— 两端确实各自有出处。
func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, 699050, 0); got != 699050 {
t.Fatalf("注入预算为 0 时的输入上限 = %d, want 699050", got)
}
if got := deriveGuardrailInputLimit(log, 0, 699050, 349525); got != 699050 {
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 699050(不受注入预算影响)", got)
}
}
+24 -8
View File
@@ -39,6 +39,11 @@ func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, o
return a.Chat(ctx, req, model.Options{})
}
// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项
// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是
// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。
func noWindowBudget() ctxBudget { return ctxBudget{} }
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
func guardrailApp(t *testing.T, keywords []string) *app {
t.Helper()
@@ -84,7 +89,7 @@ func truncate(s string, n int) string {
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
@@ -108,7 +113,7 @@ func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
cases := []struct {
@@ -145,10 +150,21 @@ func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
//
// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限
// guardrailMinInputChars,也就是接线前那个 262144 的行为。
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars {
t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars)
}
// 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关,
// 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。
if guardrailMaxOutputChars != 65536 {
t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars)
}
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
t.Fatalf("恰好到上限的输出被拦:%+v", v)
@@ -170,7 +186,7 @@ func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T)
}
// 超过上限则被拦。
over := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMaxInputChars+1)),
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)),
}}
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
t.Fatal("超出输入上限的载荷却放行")
@@ -185,11 +201,11 @@ func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
t.Parallel()
text := "这段文本里出现了违禁词。"
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(), text); !v.Allow {
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow {
t.Fatalf("未配置词表却拦下了文本:%+v", v)
}
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline()
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget())
v := checkInput(t, configured, text)
if v.Allow {
t.Fatal("配置了词表却没拦下命中文本")
@@ -235,7 +251,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
}
pipeline := a.buildGuardrailPipeline()
pipeline := a.buildGuardrailPipeline(noWindowBudget())
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, instructions),
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
@@ -256,7 +272,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
func TestGuardrailWiring_EndToEnd(t *testing.T) {
t.Parallel()
guards := guardrailApp(t, nil).buildGuardrailPipeline()
guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
adapter := &guardProbeAdapter{reply: "我在呢♪"}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
+11 -7
View File
@@ -152,9 +152,19 @@ func (a *app) wireRuntime() error {
}
a.wireVision()
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
//
// 它必须在护栏**之前**算出来:请求侧字符上限是这份预算的函数(切片 G2:
// 输入上限 = max(256 Ki, 推导预算 token 数)),否则 1M 窗口下 349525 字符的
// 附件注入会先被硬编码的 262144 上限整回合拦死。
ctxBudget := a.contextBudget(envPath)
// 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 +
// 长度上限 + 仅当配置提供时的关键词表,见 wire_guardrail.go)。
guards := a.buildGuardrailPipeline()
guards := a.buildGuardrailPipeline(ctxBudget)
instructions := a.defaultInstructions()
// 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels /
// personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层
@@ -252,12 +262,6 @@ func (a *app) wireRuntime() error {
approver := audit.NewApprover(a.log)
approvalGate := a.buildApprovalGate(approver)
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
ctxBudget := a.contextBudget(envPath)
a.loop = loop.New(loop.Options{
Agent: defaultAgent,
Agents: agents,
+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)
}
}