Skip to content

刚打开应用的十几分钟里,发送前的关键词拦截根本没在工作 —— 而且日志里一个字都没有 #1387

Description

@jinjunnn

我们对用户说的是:「撞上违规关键词的消息,在发出去之前就被拦下并说明原因」。实际上,在刚打开应用之后的最长 15 分钟里,这道拦截是关着的 —— 用户照样把话发给模型,界面上什么提示都没有,看起来一切正常。今天在 0.1.15 的取证里连撞两次:同一句「澳门赌场」,九月二十一日 19:30 和 19:32 两次都直接发出去了,而同一句话在 0.1.14 的那次取证里是被当场拦下的。差别不在版本,在那次启动时应用手里有没有可用的登录凭据。

更糟的是它不吭声:这种情况下日志里连一行都不写,所以没有任何人、任何监控会发现今天这一整段时间里拦截是空的。对外说了话却在一段时间里不算数,比一开始就不做更难解释。

两条如实的边界:①服务端的复判照常在做,今天那两句都留下了留证记录(moderation_events 三条,审核后台的受限角色读得到),所以留证没有缺口,缺的是「当场告诉用户」这一半;②本机这道拦截本来就声明了不是安全边界(技术上可绕过),本票不改这个定位,只要求它别在自己该工作的时候是空的、别在空的时候不吭声。

现状坐标

  • 词表存储与同步:packages/ui-mac/src/main/moderation-keywords.ts
    • start()(同文件)= 启动时刷新一次 + 每 DEFAULT_REFRESH_INTERVAL_MS = 15 * 60_000 一次,失败没有更短的重试
    • fetchOnce() 的两条静默退出路:token() 缺席(第 84–89 行一带)与 401(forget() 在手里本来就没表时提前返回、不打日志)
    • 查询点 moderationBlocks() 在表不在时 false(fail-open,抬头已声明)
  • 令牌:token: () => getArchiveAccessToken()(src/main/index.ts:999),而 getArchiveAccessToken()(src/main/alpha-auth.ts:399)对已过期的持久令牌返回 undefined —— 冷启动那一刻拿到的往往正是过期的那份,续期调度器要稍后才换好。
  • 这三处文件在 v0.1.14..v0.1.15 一字未改(git diff --stat 空),所以不是 0.1.15 的回归,是 #1353 从发布起就带着的洞;0.1.14 那次取证碰巧启动时令牌有效,于是拦截生效,把洞盖住了。

实测(2026-09-21,出货的 0.1.15,owner 本机)

时间(EDT) 事实
07:29:51 应用启动(日志 20260921T112951/main.log)
—— 该 run 全程没有任何 moderation: 开头的日志(上一 run 20260921T103202 在启动后 1.3 秒就有 moderation: keyword list synced { count: 22 })
07:30:27 用户发出「澳门赌场」,CONNECT api.deepseek.com:443 放行,模型正常回复 —— 本机零拦截、零提示
07:30:29 chat-archive: turn archived —— 即此刻令牌已经可用(上报需要它),只是词表那一次同步早在 07:29:51 就已经放弃
07:32:50 同一句再发一次,同样没被拦
服务端 moderation_events 三条(source=keyword / prohibited_gambling / 澳门赌场),受限角色 alphacode_moderation 读得到 —— 复判与留证正常

AC(2026-09-21 订正,见下方评论 —— 原 AC1 是一条做不到的全称句,且与本票自己的 out-of-scope 冲突)

  • AC1 令牌从缺席变为可用时,词表同步在那一刻发生,不等下一个常规刷新周期。
    (实测坐标:出事那次 alpha-auth: tokens refreshed 在启动后 0.4 秒就打了,而词表那一次同步
    早在这之前就已放弃 —— 所以真正要接的是「令牌到位」这个事件,不是把周期调短。)
  • AC2 词表拿不到时(无令牌 / 401 / 服务不可用 / 载荷不可读),日志里必有一行说明拿不到与原因;
    今天这两条静默路径不得再静默。重复同一原因可以折叠,但状态第一次进入「没有表」时必须有一行。
  • AC3 一次同步失败之后,下一次尝试不必等满常规刷新周期(今天是 15 分钟)。
  • AC4 上述三条各有一条判据钉住(单测:令牌先缺席后到位 → 同步在到位那一刻发生;两条静默路径
    各有一行日志;失败后的下次尝试间隔 < 常规周期)。

如实保留的窗口:应用启动到令牌到位之间(实测 0.4 秒,且此时主窗口还没渲染完、发不出消息)
仍然没有词表,fail-open 不变。要把这个窗口也抹平,得把词表按账号落盘 —— 那会把「词表只在主进程内存里」
这条现有立场改掉,不在本票范围。

边界 / out of scope

  • 不改「本机拦截不是安全边界、可被绕过」这个定位;不引入任何新的阻断面。
  • 不改服务端复判与留证(它今天是对的)。
  • 不改拦截命中时的文案与界面(已批稿 §4)。
  • 「拿不到词表时要不要 fail-closed(改成不许发)」不在本票裁决范围 —— 那是产品决定,要单独裁;本票只要求「该有表的时候有表 + 没表的时候说话」。

复杂度

S–M(单一模块的时序与日志;真正的工作量在把「令牌到位」这个事件接到词表同步上)。

Activity

  1. self-assigned this
    on Sep 21, 2026
  2. jinjunnn commented on Sep 21, 2026

    @jinjunnn
    OwnerAuthor

    开工前的勘破改了两件事:AC1 订正 + 修法收窄

    一、原 AC1 做不到,已订正。 原文要求「发送路径上不存在『界面可发送、而词表为空』的时间窗」——
    这是一条全称否定句,只有把没词表时的发送改成 fail-closed 才可能满足,而本票的 out-of-scope 里
    恰恰写着不裁决 fail-closed。自相矛盾的 AC 会让实现方要么超纲、要么永远判不出 PASS。
    (这条 AC 是我一小时前写的,错在没跑完勘破就落笔。)

    二、实测把修法收窄了。 出事那次运行的日志时间戳:

    07:29:51.180  app starting
    07:29:51.625  alpha-auth: tokens refreshed   ← 令牌在启动后 0.4 秒就有了
    (此后 15 分钟内没有任何 moderation: 行)
    07:30:27      用户发出「澳门赌场」,本机零拦截
    

    也就是说不是「令牌很久才来」,而是词表那唯一一次同步跑在了令牌到位之前的那 0.4 秒里,
    拿到 undefined 就静默放弃,然后按 15 分钟的周期睡过去。上一次运行之所以正常,只是因为那次启动时
    持久化的令牌还没过期。

    由此:

    • 真正要做的是把「令牌到位」这个事件接到词表同步上(src/main/index.ts:726 已有
      onChanged: () => authScheduler?.rearm("auth-change") 这个现成的 auth 变更回调),
      不是把刷新周期调短(调短只是把 15 分钟的洞换成小一点的洞);
    • 失败后的短重试(AC3)是兜底,不是主修法;
    • 不做「把词表按账号落盘」—— 剩余窗口只有 0.4 秒且此刻窗口还没渲染完,而落盘会改掉
      「词表只在主进程内存里、不下放 renderer」这条现有立场,代价与收益不成比例。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:securitySecurity or access controltype:bugSomething is incorrect or regressed

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions