Files
Agent-System/.doc/system-design/2026-09-22-t20-hardening-design.md
T

21 KiB
Raw Blame History

title, status, created, updated, owner, base_dev_sha, source_audit, source_backlog, task
title status created updated owner base_dev_sha source_audit source_backlog task
T20 加固设计决策(W4) proposed 2026-09-22 2026-09-22 Cursor 364ed7f1a3 ../audit/2026-09-20-system-triage-audit.md ../ops/long-term/2026-09-19-audit-remainder.md ../ops/short-term/2026-09-22-t20-design-items.md

T20 加固设计决策(W4)

本文件是设计决策,不是实施计划全文。实现另开 short-term;本批不改业务代码。
低风险已实现项(F5/I8)交叉链接:t20-lowrisk 任务文(当前在 worktree .worktrees/t20-lowrisk-hardening-2026-09-22,合入 dev 后本相对路径生效)。本文不重复设计 F5/I8。

核对基线:feature/t20-design-items-2026-09-22 @ 364ed7f1。


总表

ID 推荐摘要 卡产品/鉴权/环境? 建议优先级
B1 默忽视 XFF;仅可信代理 CIDR 命中时解析 否(运维 CIDR) P1
B3 按 docID 分锁;hydrate/DB I/O 出全局锁 否 P1(独立大改)
B4 主库池级 statement_timeout 分级 否 P1
B5 保留 FailChatTurn 补偿;补可观测;同事务可选 否 P2
B7 drain 超时与 AGENT_TIMEOUT / compose grace 对齐 否(运维调参) P1
B11 生产禁 GET 传 message;保留 POST body 否 P1(小)
F1 所有窗口写路径统一 generation + Abort 否 P1
F4 先隐藏死按钮;完整能力需产品选型 是(产品) 决策后
F7 先全局 401;HttpOnly cookie 独立 epic 是(鉴权专项) Cookie=P2
F8 poll/stats 去重 + generation;blob revoke 否 P1
I1 非 root USER + mem/cpu limits 部署需环境授权 设计可合
I4 三环境统一 extensions_init.sql 改卷需环境授权 设计可合
I6 systemd/cron 调备份脚本,RPO≤60m 是(环境写) 仅文档

B1 — XFF 可信代理

现状

internal/server/login_rate_limit.go 的 clientIP 无条件取 X-Forwarded-For 首跳,否则 RemoteAddr。攻击者可伪造 XFF,使登录限流桶分散,绕过爆破防护。

推荐做法

  1. 新增配置 TRUSTED_PROXY_CIDRS(逗号分隔 CIDR;空=不信任任何代理)。
  2. 仅当 RemoteAddr 落入可信 CIDR 时解析转发头:
    • 取 XFF 链中最右非可信地址(或配置 TRUSTED_PROXY_HOPS=N 剥掉右侧 N 跳)。
    • 否则一律用 RemoteAddr(剥端口)。
  3. 可选:同样规则用于审计日志 IP;登录限流 key 继续 ip|username。
  4. 默认生产 compose 不填 CIDR(直连或仅 docker 内网时安全);有 nginx/traefik 时由运维填代理网段。

风险

  • 配错 CIDR → 全站限流落到代理 IP,正当用户互锁。缓解:启动日志打印有效 CIDR;监控 login_rate_limited 按 raw RemoteAddr。
  • 多级代理跳数错误 → IP 仍可伪造。缓解:文档要求「只信直连上一跳」;优先 CIDR 白名单而非盲目 HOPS。

验收

  • 单元:伪造 XFF + RemoteAddr∉CIDR → key 用 RemoteAddr;∈CIDR → 用解析后的客户端 IP。
  • 负例:空 CIDR 时带任意 XFF 不得改变限流 key。
  • 不要求浏览器 E2E。

是否卡产品/鉴权

否。运维配置即可。


B3 — docgov / docrevision 锁拆分

现状

  • docrevision.Service:进程级 sync.Mutex;hydrateDocLocked 在持锁时打 PG。
  • docgov.GovernanceEngine:RWMutex;AcquireLease 等路径在锁内 getDoc / persistLease(含 DB)。

结果:单文档慢查询阻塞全部文档的修订/租约路径。

推荐做法(两阶段)

阶段 A(优先,行为不变)

  1. 锁外 hydrate:先在无锁或 doc 级锁下从 PG 加载到临时结构,再短临界区合并进内存 map(CAS / version 校验)。
  2. 按 doc_id 分锁:map[string]*sync.Mutex + 全局 RWMutex 仅保护「分锁表」本身;文档间并行。
  3. DB 写:短临界区准备参数 → 锁外执行 SQL → 短临界区提交内存;失败回滚内存镜像。
  4. 租约继续依赖既有 fencing token / TTL;不引入新分布式锁。

阶段 B(可选)

  • 热路径逐步改为「PG 为 SSOT、内存为缓存」,减少 hydrate 频率;需单独容量评估,不与 A 绑死。

风险

  • 分锁 + 锁外 I/O 易引入 TOCTOU;必须用 fencing / 乐观版本。
  • 死锁:统一锁序 globalMeta → docID(docID 字典序若需多文档)。
  • 回归面:document_commit_concurrency_pg_test、lease 单测、修订 idempotent 路径。

验收

  • 基准:两文档并行 hydrate/commit,全局锁持有时间不覆盖对方 DB RTT(可用 race + 计时断言或日志采样)。
  • 既有 PG 并发提交 / lease busy 负例全绿。
  • go test -race 覆盖 docrevision + docgov 包。

是否卡产品/鉴权

否。工程改造。


B4 — statement_timeout 分级

现状

  • 主库:bizdev.NewService 用 pgxpool.New(dsn),无 statement_timeout / MaxConns 调优。
  • KB:kb.OpenPool 仅设 MaxConns=8,仍无 timeout。

长查询可占满连接,拖垮健康检查与短 API。

推荐做法

  1. 抽出共享 db.OpenAppPool(ctx, dsn, PoolProfile)(或扩现有 internal/db):
    • default:statement_timeout=30s,MaxConns 按环境(建议 dev 8 / test 16 / main 可配)。
    • migrate:启动迁移会话显式抬高或 0(仅迁移连接)。
    • long:PI/索引/报表等路径在事务内 SET LOCAL statement_timeout = '15min'(或绑定 AGENT_TIMEOUT)。
  2. 禁止在业务 SQL 字符串里散落裸 timeout;分级只通过池默认 + SET LOCAL。
  3. 超时映射为 typed 错误信封(503/504 或既有 code),不把 PG 原文回前端。

风险

  • 误伤合法长事务(修订大文档、embedding 批)。缓解:long profile + 白名单调用点。
  • 与上下文 context.WithTimeout 双重取消;以更严者为准,日志区分来源。

验收

  • 单元/集成:池 RuntimeParams 含 statement_timeout;人为 pg_sleep 超过默认 → 连接释放且 API 失败可控。
  • 迁移路径仍能跑完整 goose 链。
  • 健康检查不因无关慢查询长期饿死(抽样)。

是否卡产品/鉴权

否。


B5 — chat 消息与 turn 原子性

现状

  • chat_turns 与 chat_messages 非同一 SQL 事务写入。
  • 补偿已存在:session.sendMessageInternal 在 clientTurnID != "" 时 defer FailChatTurn;用户消息落库失败显式 FailChatTurn;成功路径 CompleteChatTurn(见 internal/chat/session.go)。
  • AcquireChatTurn 提供租约/幂等;过期 processing 可打 failed。

因此「完全无补偿」的审计表述已过时;剩余是窗口期孤儿与可观测性,不是从零设计补偿。

推荐做法

  1. 本阶段不强制同事务(改动面大,且消息与 turn 可能跨连接)。
  2. 加固现有补偿:
    • FailChatTurn / CompleteChatTurn 错误打结构化日志 + 计数(勿静默 _ =)。
    • 周期/启动扫描:status=processing AND lease_expires_at < now() → failed(已有单条路径,升为批量)。
    • 可选:用户消息已写、turn 未绑定 user_message_id 的孤儿告警。
  3. 可选后续(P2):同一 pgx.Tx 内 INSERT message + UPDATE turn;失败整回滚。仅在确认补偿指标仍噪声大时做。

风险

  • 同事务可能拉长锁时间、与 stream 生命周期纠缠。
  • 过度扫描压力;限制 batch size 与频率。

验收

  • 文档/测试明确:persist 失败 ⇒ turn=failed;完成 ⇒ completed + assistant id。
  • 注入 fail 点:用户消息成功后进程崩溃 → 租约过期后 turn 终态可查,可重放同 client_turn_id 不双写助手(幂等语义保持)。
  • 不把「缺同事务」单独列为发布 blocker。

是否卡产品/鉴权

否。


B7 — graceful shutdown vs AGENT_TIMEOUT

现状

  • cmd/server/main.go:Shutdown 固定 5s。
  • Compose / .env:AGENT_TIMEOUT 默认 30m(示例可达 60m)。
  • Compose 无 stop_grace_period → Docker 默认 ~10s 后 SIGKILL。

结果:滚动重启时长任务被砍;in-flight SSE/PI 无法 drain。

推荐做法

  1. 引入 SHUTDOWN_DRAIN_TIMEOUT(默认 min(AGENT_TIMEOUT, 35m) 或显式 31m),main 用其做 Shutdown context。
  2. Shutdown 顺序:
    • 停止接受新 HTTP(http.Server.Shutdown);
    • 标记「拒新 Agent/taskrun」(若尚无 gate,加 atomic 或复用 ready);
    • 等待 in-flight 结束或 drain 超时;
    • 再关池 / worker。
  3. 三环境 compose:stop_grace_period ≥ SHUTDOWN_DRAIN_TIMEOUT(建议 +30s 缓冲)。
  4. 不要求 drain 等满整个 AGENT_TIMEOUT 才退出运维窗口:可由运维设更短 drain,并接受超时强杀;但 5s 与 30m 的错配必须消除。

风险

  • 过长 grace 拖慢部署。缓解:test/dev 可较短;main 对齐真实 Agent 上限。
  • 任务被拒新后客户端重试风暴。缓解:503 + Retry-After;前端识别。

验收

  • 配置单测:env 解析。
  • 手册/注释:compose grace ≥ drain。
  • 手工(授权环境):起长 sleep handler 或短 Agent,SIGTERM 后观察是否在 grace 内结束或明确超时日志(实施时做)。

是否卡产品/鉴权

否。运维调参。


B11 — GET→POST EventSource(消息体)

现状

  • handleChatStream:POST JSON 与 GET query(含 message)并存,注释为 EventSource/调试。
  • 生产前端 web/src/api/chatStream.ts 已用 POST + body,不依赖 EventSource GET。

GET 将用户正文写入 URL → 代理/访问日志泄露;长度受限。

推荐做法

  1. 生产路径:仅允许 POST /api/v1/chat/stream 带 JSON body(维持现状前端)。
  2. GET:
    • 首选:直接拒绝 GET(405),或仅当 APP_ENV=dev 且 CHAT_STREAM_GET_DEBUG=1 允许,且禁止 message query(只允许已有 run 订阅类参数,若有)。
    • 过渡:GET 若带非空 message → 400,文案指向 POST。
  3. 文档/OpenAPI 删除「EventSource 传消息」示例。

风险

  • 外部脚本仍用 GET。缓解:变更说明;405 明确。
  • 无。前端已 POST。

验收

  • 测试:GET+message → 4xx;POST 同行仍 200 流式(现有测即可)。
  • rg 'EventSource|/chat/stream.*GET' web/ 无生产依赖。

是否卡产品/鉴权

否。


F1 — useChatWindow generation 所有权

现状

  • loadInitialWindow 已用 generationRef + AbortController。
  • loadOlder / loadFullDay / 锚点加载等未绑定同一 generation:切会话后晚到响应仍可能 setDayStack / 写 saveSessionWindowCache。
  • 已有 useChatWindow.race.test.ts 覆盖「主窗口」模式,未覆盖 loadOlder。

推荐做法

  1. 约定:凡会写 dayStack / 缓存 / phase 的异步函数必须:
    • 入口捕获 const gen = generationRef.current(或 ++ 仅由 session effect 负责);
    • fetch 传 AbortSignal;
    • resolve 后若 gen !== generationRef.current 则 return,禁止 setState/写缓存。
  2. sessionId 变化时现有 effect 已 abort + generation++;保持单一所有者(该 effect),子函数只读。
  3. 扩展单测:切会话后 resolve 的 loadOlder 不得污染新会话缓存。

风险

  • 漏改某一路径。缓解:checklist + 测试表驱动。
  • abort 触发 AbortError 日志噪音;吞掉或降级 debug。

验收

  • 单测:A→B 切换后 A 的 loadOlder resolve 不改变 B 的 stack/cache。
  • 手工:快速连点会话,历史不串台。

是否卡产品/鉴权

否。


F4 — adminResetPassword 产品选择

现状

  • UI:UsersSection 硬编码临时口令 ResetPass123,调用 authClient.adminResetPassword。
  • API 客户端:直接 throw new Error('请通过后端管理员接口重置密码') —— 死功能。
  • 后端:有 POST /api/v1/auth/change-password(自助),无管理员重置端点;创建用户已支持初始口令 + must_change_password。

产品选项(择一)

选项 做法 安全
A. 隐藏/禁用(推荐短期) 去掉或 disable「重置密码」按钮;文案引导「停用 + 新建」或工单 无假成功
B. 服务端生成临时口令 POST /api/v1/admin/users/{id}/reset-password:服务端随机口令、哈希入库、must_change_password=true、审计;响应一次性明文仅回管理员 禁硬编码;禁日志打口令
C. 仅发重置链 邮件/线下渠道发 token 需通知系统;当前栈可能不具备

推荐:先 A(trivial、可立即做);产品确认后做 B(对齐创建用户语义)。禁止继续展示会失败却暗示成功的按钮;禁止客户端固定 ResetPass123。

风险

  • B 的明文回传需 HTTPS + UI 一次性展示;截图泄露。
  • 管理员重置自己账户的策略需产品定(禁止或二次确认)。

验收

  • A:UI 无可用重置按钮或明确「未开通」;无硬编码口令字符串。
  • B:API 鉴权 admin-only;负例非管理员 403;用户登录后强制改密;审计有 actor。

是否卡产品/鉴权

是(产品)。A 可不经产品;B/C 需产品签字。


现状

  • 会话 token 在 localStorage(smdf_auth_session);请求头 Authorization: Bearer。
  • XSS 可窃 token;无统一「任意 API 401 → 清会话回登录」网关(多处各自处理)。

推荐做法(分阶段)

阶段 0(可先做,不改 cookie)

  • 集中 apiFetch:401 → clearSession + 跳转登录;避免半登录态。

阶段 1(独立鉴权 epic / ADR)

  1. 登录响应 Set-Cookie:HttpOnly; Secure; SameSite=Lax(跨站场景再评 Strict/None)。
  2. 服务端读 cookie 建会话;逐步废弃 localStorage token(兼容双读一期)。
  3. 若 cookie 会话:CSRF 策略(SameSite + 写操作 CSRF token 或自定义头)。
  4. SSE/fetch stream 带 credentials: 'include'。

不在本设计批实现阶段 1。

风险

  • CSRF、多标签登出、移动/非浏览器客户端。
  • 与现有 Bearer 测试夹具大面积改动。

验收

  • 阶段 0:任意受保护 API 401 后 UI 回登录且 storage 清空(单测/组件测)。
  • 阶段 1:ADR + 安全负例(JS 读不到 cookie;无 token 的 XSS 载荷不能复用会话)。

是否卡产品/鉴权

是(鉴权专项)。阶段 0 否;阶段 1 需独立授权。


F8 — poll 去重 + App.tsx stats generation

现状

  • App.tsx:recoverableRunIds 每 3s Promise.all(getTaskRun);依赖 recoverableRunKey,但无 in-flight 合并——慢请求可重叠。
  • 同文件:fetchChatCategories / fetchChatDates 随 activeSessionId 触发,无 generation——快切会话可后写先到。
  • 多处 URL.createObjectURL;部分路径有 revokeObjectURL,需审计遗漏。

推荐做法

  1. Run 轮询:模块级或 hook 内 Map<runId, Promise>;同 runId 共用飞行中请求;终态入 skipped 后不再请求(已有 403/404 skip,扩展终态)。
  2. Stats:statsGenRef;session 变更 ++;resolve 时比对再 setCategoryStats/setDateStats。
  3. Blob:导出/预览统一 try/finally revoke;React 卸载时 revoke。
  4. 优先抽 hook(useRecoverableRunPoll / useSessionChatStats),减轻 App.tsx 继续膨胀(与既有拆分方向一致)。

风险

  • 去重导致状态稍旧一拍;可接受(3s 轮询)。
  • 漏 revoke → 内存涨;用测试断言调用次数。

验收

  • 单测:快切会话时旧 stats 不落地;并行 poll 同 runId 只 1 个 in-flight。
  • 手工:多 streaming 卡片时网络面板无指数级 getTaskRun。

是否卡产品/鉴权

否。


I1 — 非 root + 资源限制

现状

  • deploy/app/Dockerfile:最终镜像以 root 跑 /app/server;无 USER。
  • Compose:无 mem_limit / cpus / deploy.resources。

推荐做法

  1. Dockerfile:创建 appuser(固定 uid/gid,如 10001);chown 可写目录(DATA_DIR);USER appuser。
  2. 确认挂载卷权限:宿主机 data/* 对 uid 可写;文档写明。
  3. Compose(dev/test/main 的 app + events):设 mem_limit/mem_reservation、cpus(数值按主机容量,main 更严);避免无上限 OOM 连坐。
  4. 保持 cap_drop: [ALL] 等为后续可选项;本设计最低线是非 root + 内存上限。

风险

  • 卷权限导致写入失败。缓解:先在隔离 compose 冒烟。
  • 限制过紧 → PI 侧杀进程。缓解:分服务限额(runner 另算)。

验收

  • 镜像 docker run 内 id 非 root。
  • 健康检查与一次写 data 冒烟 PASS。
  • compose config 含 resources。

是否卡产品/鉴权

设计与 Dockerfile 改动不卡产品;部署到共享 test/main 需环境授权。


I4 — init mount 统一

现状

环境 init 挂载
dev context_init.sql(扩展+大量业务 DDL)
test extensions_init.sql(仅扩展)
main goose/00001_baseline.sql
shared KB kb_init.sql(保留,不在本项)

全新卷行为三套不一致;dev 易与 goose 双源漂移。

推荐做法

  1. 业务库(dev/test/main):一律挂 deploy/postgres/extensions_init.sql → /docker-entrypoint-initdb.d/01-extensions.sql。
  2. 表结构只走 goose;废弃业务库对 context_init.sql / baseline 文件作为入口(文件可留在 archive 或注释「勿挂载」)。
  3. 文档:空卷启动顺序 = Postgres init 扩展 → app 迁移。
  4. 已有数据卷不因改挂载自动重建;仅影响新卷。

风险

  • 有人依赖 context_init「一键空库有表」而无 app 迁移。缓解:操作规范写明必须跑迁移。
  • main 从 baseline 文件习惯变更;晋升/灾备手册更新。

验收

  • 三环境 compose diff:业务 PG 的 init 路径相同。
  • 新卷:仅扩展存在 → 起 app → goose 到当前 schema → health schema 一致。

是否卡产品/鉴权

设计否;重建卷/改共享环境需授权。


I6 — 备份调度(仅文档)

本项不实施。 在宿主机安装 systemd/cron 属于环境写,需单独授权。

现状

  • 脚本:scripts/backup-system-db.sh <dev|test|main> <dir> + restore drill。
  • 无仓库内定时器;RPO 依赖人工。

推荐做法(授权后由运维执行)

  1. 调度:每环境独立 timer(建议 main 每 60m;test 每 6h;dev 每日或关)。
  2. 单元示例(示意,勿把 secret 写入仓库):
# /etc/systemd/system/agent-system-db-backup-main.timer
[Timer]
OnCalendar=hourly
Persistent=true

# service ExecStart=
# /path/to/repo/scripts/backup-system-db.sh main /var/backups/agent-system/main

或 cron:0 * * * * 调同一脚本。

  1. 保留:本地保留 ≥24 份小时备份或 7 日;远端拷贝另策。
  2. 口令:${HOME}/.secrets/backup-env 或环境文件;禁止进 git。
  3. 验收(授权实施后):连续 2 个周期出现新 dump + manifest;restore-system-db-drill 在隔离目录 PASS;RPO 墙钟 ≤60m(main)。

风险

  • 备份含 chat 明文 → 目录 ACL / 加密。
  • 与运行库抢 IO:错峰或 nice/ionice。

是否卡产品/鉴权

是(环境授权)。本文只登记方案;实施另开 operations 任务。


明确不在本文范围

ID 说明
F5 ErrorBoundary 栏级 — W2 lowrisk 已实现;见该任务文
I8 testing.Short() 跳过 docker build — W2 lowrisk 已实现
B2/B6/B8/B9/B10, F2/F3/F6, I2/I3/I5 W2 低风险批

实施拆批建议(供后续认领)

批 项 备注
S1 小改 B11, F1, F8, F4-A 可无环境写
S2 配置/池 B1, B4, B7 需回归
S3 锁 B3 独立 mutex
S4 镜像/compose I1, I4 部署另授权
S5 决策后 F4-B, F7-1 产品/鉴权
S6 运维 I6 仅授权后
观测 B5 可不阻塞 S1

相关链接