Skip to content

Commit 4dd8dd4

Browse files
committed
fix(mcode-trajectory-studio): close the confidentiality, bounding and JSONL-reporting defects
One commit for the work on this branch since the last reviewed head. Nothing outside `plugins/weekbin/mcode-trajectory-studio/` is touched. Redaction, tuned to the form credentials actually take on disk - The redactor could not see that form. The runtime stores tool results as JSON *text*: in a real projection, 109,462 of 118,109 message rows carry `tool_call_result_data` as a string, so a credential arrives escaped — `{\"api_key\":\"…\"}` — and a key/value rule that requires the key and its separator to be adjacent never fires. A string value that parses as JSON is now walked as structured data (rewritten only when a redaction actually fired, so JSON tool results are never reformatted to protect nothing), and the key/value rule tolerates and re-emits any backslash run on either delimiter. Measured through the shipped MCP path: the escaped pairs whose key names a credential go from none redacted to 54 pairs, 0 surviving. - A permissive value pattern let the *outer* pair of an escaped document match first, so the scanner stepped past the sensitive pair nested inside it. There is a regression test for that shape. - Rules were not idempotent: the unquoted value class stops at `]`, so `alreadyRedacted` never saw a complete marker and every sweep added a bracket; and a marker a small `maxLength` had cut short was re-redacted on the next pass, shifting the omitted count. Both fixed at the cause, and idempotence is asserted as a property over the corpus × four option sets rather than by inspection. - Structured keys are judged by word-boundary containment instead of an exact-name set, so `clientSecret`, `refreshToken`, `accessToken`, `authToken`, `privateKey`, `apiSecret` and `xApiKey` are covered, while counters (`inputTokens`, `total_tokens`) and the addressing identifier (`sessionId`) are not — treating a session id as a secret returns `"[redacted]"` from `/api/sessions` and breaks every following request. A task's description and command are redacted at the data source, so no consumer can forget. - Added coverage for `github_pat_`, `npm_`, `hf_`, Google `AIza`/`ya29.`, Azure `AccountKey=` and an unlabelled JWT, and a connection string whose password itself contains `@` no longer stops at the first `@` and leaves the tail in the clear. Over- redaction is pinned from the other side: `npm_config_registry` and `HF_HOME` survive intact and a 13-digit epoch is not read as a phone number. - E-mail addresses and phone numbers are masked on the MCP egress only, because that output reaches a model context while the panel is the reader's own screen. The home directory, the data directory and operator-configured roots fold to `~` wherever they appear — including the doubled-backslash form a Windows path takes inside a JSON column — and another account's home keeps its shape and loses the name. - `/api/overview` returned `stats.workspaceDir` raw beside a `session.workspaceDir` that was already folded; both are folded now, with an HTTP regression. Bounding what can be reached - Per-string limits were not a response limit: 1000 full records serialised to ~20 MB. An aggregate byte budget plus depth and entry ceilings were added; the page is trimmed in order and reports `truncated`/`omitted` so the client pages on `nextOffset`. MCP emits the payload twice (text and `structuredContent`), so its record-list budget is half. - The SQLite read had no per-row ceiling, so one multi-megabyte row was materialised and parsed whole. The size test now runs inside SQL and an oversized row is returned as `oversized` with its byte count rather than silently dropped. - The `git rev-parse` probe inherited the whole `process.env` on a directory that came out of session data. It now receives an exact allowlist plus fixed `-c core.fsmonitor=false -c credential.helper=`. The user's `~/.gitconfig` is deliberately kept (`safe.directory` lives there); an `alias.rev-parse` in a repository's own config cannot shadow the builtin, which was measured. - Diagnostics were unbounded — one warning per failed read, echoed in full on every `trajectory_list`. The list is capped and reports its dropped count. The JSONL line cap, and its reporting - The cap is enforced per chunk rather than after appending, so a single unterminated line no longer grows the buffer to the size of the file. Measured inside an instrumented copy of the module: the pending buffer peaks at 2.06 MiB (the cap plus one chunk) for a 10, 100 and 400 MiB stream. - An oversized line whose newline arrived in the same chunk as the byte that crossed the cap was discarded *without being counted*: only the pending-buffer path incremented `droppedOversized`, so which path a line took — counted or silent — depended on where the chunk boundary fell. Both paths count now, they cannot double count (the discarding branch consumes the overflowing line's newline and returns before the per-line test), and a test asserts the two deliveries agree. Containment, git's environment, and the panel - On Linux the opened descriptor is re-verified through `/proc/self/fd`, so an intermediate directory swapped between canonicalization and open is refused; where `/proc` is absent that residual race is the documented boundary instead of an unstated one. - The panel opens in `summary` detail. The markup shipped the toggle `checked` while the state defaulted to `full`, so the panel fetched content the control said it was not fetching; the control is derived from the state now so the two cannot drift apart. Docs - README (both languages) carries the confidentiality contract and its known limits; DESIGN.md gains the threat model and the accepted boundaries, and the validator section is renumbered 6.6 → 6.7 because the inserted section took 6.6. - An unused export that returned an unredacted, unbounded object was removed rather than left as a channel that would be unredacted the day someone wired it up. Tests: 166 plugin (was 138 at the reviewed head), 523 repo-wide, 0 failures. tools/compat-matrix.mjs asserts 166 pass / 0 skip on this runtime.
1 parent 9d5b6a6 commit 4dd8dd4

29 files changed

Lines changed: 1982 additions & 206 deletions

‎plugins/weekbin/mcode-trajectory-studio/.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcode-trajectory-studio",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "只读查看本机 MiniMax Code 会话轨迹:轮次、步骤、LLM 与工具的墙钟耗时、token 用量、工具入参与结果、上下文拆分、压缩与子代理任务,并附带一个本地交互式面板。数据取自本地运行时自身的 SQLite 投影;插件不写入任何文件、不联网、不收集遥测。面板仅监听 127.0.0.1,每个 API 路由都要求一份随进程新生成的访问凭据。需要 Node.js 22.13.0+(内置 node:sqlite 的下限),已验证范围与 mcode engines 一致:>=22.19 <23 或 >=24 <27;全文检索另需内置 SQLite 带 FTS5,22.13.0-22.18.x 与 23.x 缺失时搜索降级并告警。 / Read-only inspection of local MiniMax Code session trajectories: turns, steps, LLM and tool wall-clock time, token usage, tool arguments/results, context breakdown, compactions, sub-agent tasks, plus a local panel. Data comes from the local runtime SQLite projection; the Plugin writes no files, no network, no telemetry. The panel listens on 127.0.0.1 only and requires a per-process capability token on every API route. Requires Node.js 22.13.0+ (the built-in node:sqlite floor); the verified range matches mcode engines: >=22.19 <23 or >=24 <27. Full-text search also needs FTS5 in that bundled SQLite; absent in 22.13.0-22.18.x and 23.x, where search degrades with a warning.",
55
"author": {
66
"name": "weekbin",

‎plugins/weekbin/mcode-trajectory-studio/DESIGN.md‎

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ turn_mu6wnx9w_ee64qg 2042 457 90
187187
| 库 2.5 GB 且运行时正在写(WAL 17 MB) | 一律 `file:…?mode=ro` 只读打开,绝不写;不开 exclusive |
188188
| 表名 `local_runtime_*`、列 `columnar_version` 属内部实现,可能变 | 用 `json_extract` 宽松读取 + 缺字段降级;不依赖固定列序;schema 探测失败即降级到 fallback |
189189
| 大结果集 | 强制分页 + 行数上限;按 `session_id, id` 索引走 |
190-
| 隐私(会话含敏感内容) | 本地 127.0.0.1 绑定 + Host/Origin 校验 + CSP + 无外网;不做任何上传 |
190+
| 隐私(会话含敏感内容) | 本地 127.0.0.1 绑定 + Host/Origin 校验 + CSP + 每进程 capability token + 两层脱敏 + 无外网;不做任何上传 |
191191
| 只读打开时运行时正在 checkpoint | `mode=ro` 下 SQLite 用 WAL 只读快照,安全 |
192192

193193
---
@@ -270,6 +270,11 @@ turn_mu6wnx9w_ee64qg 2042 457 90
270270

271271
### 6.2 目录结构(双布局,兼容 0.3.x 与 0.4.0+)
272272

273+
> 下面这份清单是**设计时的快照**(表内版本 `0.1.0` 即当时的值),保留是为了记录当初的双布局意图;
274+
> 当前版本以 `plugin.json` 为准,实际的服务端模块见 `server/`:`config` · `json` · `sqlite` · `fsutil`
275+
> · `redact` · `git` · `store` · `sessions` · `stats` · `tasks` · `events` · `search` · `jsonl` · `http`
276+
> · `mcp` · `node-version` · `main`。
277+
273278
```
274279
plugins/weekbin/mcode-trajectory-studio/
275280
├── README.md
@@ -283,10 +288,10 @@ plugins/weekbin/mcode-trajectory-studio/
283288
│ └── mcode-trajectory-studio/
284289
│ └── SKILL.md # 字节一致副本(0.3.x & 校验器)
285290
├── server/
286-
│ ├── main.mjs # 入口:--stdio / --http
291+
│ ├── main.mjs # 入口:--stdio(默认) / --serve / --doctor
287292
│ ├── mcp.mjs # MCP JSON-RPC(stdio)
288293
│ ├── store.mjs # 数据层:SQLite 只读 + JSONL fallback
289-
│ ├── schema.mjs # schema 探测/降级
294+
│ ├── sqlite.mjs # schema 探测 / 只读打开 / FTS5 探测
290295
│ ├── http.mjs # 本地 HTTP + API + 静态资源
291296
│ └── redact.mjs # 脱敏 + 长度封顶
292297
├── web/
@@ -345,21 +350,89 @@ plugins/weekbin/mcode-trajectory-studio/
345350

346351
- 任何读取的 canonical(realpath)结果必须落在 canonical `dataDir` 之内
347352
- 末段组件不得是 symlink;用 `O_NOFOLLOW` 关闭 realpath→open 之间的窗口
353+
- **打开后再校验一次**:Linux 上读 `/proc/self/fd/<fd>` 拿到真正被打开的 inode 路径,再对 `dataDir`
354+
校验 → 关闭「中间目录在 realpath 与 open 之间被替换」的窗口。无 `/proc` 的平台(macOS / Windows)
355+
该竞态不在防护范围内,且这是**明确写进文档的**接受边界,不是隐含假设
348356
- 用已打开 fd 做 fstat 取 size、做 stream 读 → 检查与读取是同一个 inode(无 TOCTOU)
349357
- 每次目录遍历逐层用 `isDirectory()` 过滤(dirent 对 symlink 目录返回既非文件也非目录)
350358
- canary 测试覆盖:目录 symlink / 两级跳转 / 相对 symlink / 末段文件 symlink / 根自身是 symlink,
351-
且每条都配正向对照
359+
且每条都配正向对照;另加 `/proc` 后校验的机制测试与「同名前缀兄弟目录不算在内」测试
352360

353361
脱敏(覆盖凭据在磁盘上的真实形态):
354362

355363
- 规则**有序**:私钥块 → 连接串内联凭据 → 整个 Authorization 头(含 scheme)→ 裸 scheme+token
356-
→ provider key → AWS key id → 键值对
357-
- 键值对允许 key 与分隔符之间夹引号(`{"api_key":"…"}`),替换时保留引号形状 → 结果仍是合法 JSON
358-
- 规则幂等(源头发过一次、出口再 sweep 一次不会二次破坏)
359-
- 结构化键用**精确名**判定(保住 `inputTokens` 等计数),自由文本用**带分隔符的包含**判定
364+
→ provider 前缀 key → 定长 provider token → AWS key id → Azure AccountKey → 无标签 JWT
365+
→ 转义 JSON 键值对 → 普通键值对 → PII(受开关控制)
366+
- **两层,缺一不可**。凭据在磁盘上的真实形态是「JSON 文档存在 JSON 字符串里」:`tool_call_result_data`
367+
是 TEXT 字段(真实投影 118,109 行中 109,462 行如此),所以到达脱敏器时是 `{\"api_key\":\"…\"}`。
368+
- A 层:字符串值若能 `JSON.parse` 成对象/数组,就按结构化数据再走一遍按键名判定;**只有真的改动了
369+
才重写该字符串**,否则原样返回(否则面板里每个 JSON 工具结果都会被重新排版而什么也没保护到)。
370+
深度上限 4 层、单值上限 512 KiB。
371+
- B 层:键值规则的分隔符允许带任意层反斜杠并原样回填,因此对**不是合法 JSON** 的文本(截断、
372+
与散文混排)同样命中。规则的值**不允许跨越转义引号**——否则转义文档里的外层非敏感键会先把整段
373+
吃掉(回调看到无害的 key、返回原样,而扫描指针已经越过内层敏感对),内层凭据就此漏网。这条
374+
边界是实测出来的,测试里有专门的反例。
375+
- 键值对允许 key 与分隔符之间夹引号(`{"api_key":"…"}`),替换时保留引号形状 → 结果仍是合法 JSON;
376+
连接串的 userinfo 段用贪婪匹配到**最后一个** `@`,因此密码本身含 `@` 时不会只脱一半而漏出尾巴
377+
- **规则幂等**,且这是可测的性质而不是声明:`redact.test.mjs` 对整份语料 ×4 组选项断言
378+
`redact(redact(x)) === redact(x)`。三处实现细节是它成立的原因——未加引号的值字符类拒收 `]`,
379+
因此 `alreadyRedacted()` 额外接受缺右括号的 `[redacted`;`alreadyRedacted()` 还接受标记的**任意
380+
前缀**,否则小 `maxLength` 把标记截成 `[re` 后会被当作新值重新脱敏,标记逐轮变长;截断标记自身
381+
也被识别,避免第二次 sweep 去截断第一次的标记、让 omitted 计数漂移
382+
- 定长 provider token 用长度锚定而不是只看前缀:`npm_config_registry` / `HF_HOME` 是普通环境变量,
383+
只按 `npm_` / `hf_` 前缀匹配会把它们一起脱掉,毁掉用户打开面板想看的东西
384+
- 结构化键与自由文本都用**带词边界的包含**判定,`session[_-]?id` 例外(它是寻址标识不是凭据),
385+
因此 `clientSecret` / `refreshToken` / `accessToken` / `authToken` / `apiSecret` / `xApiKey` 这类
386+
camelCase 变体一律命中,而 `inputTokens` / `total_tokens` 等计数与 `sessionId` 等标识符原样保留
360387
- 任务 description/command 在数据源头脱敏;会话 title 就地替换凭据子串、保留其余文本
361-
- 每个出口(MCP tool result、HTTP API response)整体 sweep 一次,上限取各面已有的最大上限
362-
(只脱敏、不额外截断),因此日后新增字段不会漏
388+
- 每个出口(MCP tool result、HTTP API response)整体 sweep 一次,带上边界、深度、广度上限;
389+
**MCP 的错误分支走同一条 sweep**——只在成功时才脱敏的出口不算边界
390+
- **路径折叠**:家目录、数据目录(`MINIMAX_DATA_DIR`,覆盖家目录在容器/CI 里被放到别处的情况)、
391+
以及 `MCODE_TRAJECTORY_REDACT_ROOTS` 追加的根,在**任意位置**折叠为 `~`,并同时折叠 Windows 路径
392+
在 JSON 列里双反斜杠的形态;**其他账号**的家目录保留形状、去掉账号名(`/home/<user>/…`)。
393+
该开关只可能多折叠、不可能少折叠,因此它与监听地址不同:它不是一条能削弱防护的配置面
394+
- **PII(邮箱 / 电话)只在 MCP 出口打码**:MCP 结果会进入模型上下文,面板是读者自己的屏幕。两个
395+
出口共用一套规则、只有一个开关不同,这个区别在代码里是一处显式传参,不是隐含行为
396+
397+
数据量与进程卫生:
398+
399+
- SQLite 侧与 JSONL 侧都有**单行上限**:JSONL 2 MiB(缓冲区随分块即时封顶),SQLite `data_json`
400+
8 MiB。超限行以 `oversized` + 字节数上报——既不是整行读进内存,也不是静默丢弃;写 SQL 时用
401+
`CASE WHEN length(data_json) <= ?` 让判定在 SQL 内侧完成,超限值根本不进 JS 字符串
402+
- 诊断列表有上限(`WARNINGS_MAX = 64`),并回传 `warningsDropped`;长驻 MCP server 每次读失败都会
403+
追加一条,无上限即慢泄漏,而这份列表每次 `trajectory_list` 都会整体回传
404+
- 唯一执行外部命令的地方是 `git rev-parse`:传**白名单环境变量**而不是整个 `process.env`
405+
(宿主导出的凭据不会被继承),固定 `-c core.fsmonitor=false -c credential.helper=`,并关掉
406+
`GIT_ASKPASS` / `SSH_ASKPASS` / 分页器。用户自己的 `~/.gitconfig` **故意保留**:`safe.directory`
407+
在那里,切断它会让所有非本用户 checkout 静默退化为按路径分组;探针不从中读取任何值
408+
(`rev-parse` 无 hooks、无分页器、不查凭据),且**实测**仓库自带 `.git/config` 里的
409+
`alias.rev-parse` 无法劫持内置命令
410+
411+
### 6.6 威胁模型与已知限制
412+
413+
写清楚哪些是防护、哪些是接受边界:
414+
415+
| 威胁 | 处置 |
416+
|---|---|
417+
| 网页(CSRF / DNS rebinding)读取本机面板 | Host 精确等于监听 authority + Origin 同源 + `Sec-Fetch-Site`;Host 精确比对天然免疫 rebinding |
418+
| **本机其他进程**读取面板 | 每进程 capability token(256 位 CSPRNG,只在 fragment,内存态)。栅栏挡不住进程,凭据才是授权 |
419+
| 凭据随 `full` 明细走出本机 | A+B 两层脱敏 + 出口 sweep + 出口 PII;实测覆盖全投影中所有转义形态的敏感键值对 |
420+
| 会话数据被上传 | 无网络目的地、无遥测;唯一的出口是 MCP 结果进入模型上下文,故 PII 只在这一侧打码 |
421+
| 恶意 symlink 把读操作引到 dataDir 之外 | canonical 化全路径 + `O_NOFOLLOW` + `/proc/self/fd` 复核;无 `/proc` 平台的残余竞态为文档化边界 |
422+
| 超大行/超大响应耗尽内存 | 单行上限 + 单串上限 + 单响应字节预算,超限一律上报而不是静默丢弃 |
423+
| 仓库自带 git 配置劫持探针 | 白名单 env + 固定 `-c`;alias 劫持内置命令已实测不可行 |
424+
425+
已知限制(**不是**待办,是边界):
426+
427+
- 脱敏器认的是凭据的**形状**与**凭据名**;既无标签、也不符合任何 provider 形状的高熵字符串不在
428+
识别范围内。这是拒绝「用启发式去猜一切」的自觉选择:过度脱敏会连带毁掉读者要看的内容
429+
- PII 只覆盖邮箱与电话,且只在 MCP 出口;姓名/地址/客户名不在模式范围
430+
- 未列入折叠根的绝对路径原样返回(`summary` 的 `workspaceDir` 亦然),需要用户显式加根
431+
- **面板 capability 会随工具结果进入会话记录**:运行时把工具结果落盘,并在后续轮次把上下文发给模型
432+
服务。缓解是它**随进程消亡**且面板只监听回环。希望凭据完全不入模型上下文时,用
433+
`node server/main.mjs --serve` 在终端自行启动即可(URL 只打印在终端)
434+
- `--doctor` 原样打印数据目录与库路径:它是给人看、由人决定是否分享的诊断面
435+
363436

364437
渲染层:
365438

@@ -373,10 +446,14 @@ plugins/weekbin/mcode-trajectory-studio/
373446

374447
- SQLite 一律只读打开(`readOnly: true`);不写入任何会话产物
375448
- 插件不创建任何文件:不写 PLUGIN_DATA、不写端口文件(面板端口只出现在工具返回值与 stdout)
376-
- 单行 / 单响应大小上限;JSONL 单行 2 MiB 上限
449+
- 单行 / **单响应**大小上限:JSONL 单行 2 MiB;事件列表按序列化字节裁剪到 4 MiB(MCP 因一次结果
450+
同时带 text 与 structuredContent,取一半预算),并回传 `truncated` / `omitted` 让客户端翻页
451+
- JSONL 读入的缓冲区**逐块**封顶:单行无换行符时不会先累积到整文件再判断,而是超过 2 MiB 即丢弃
452+
该行并跳过到下一个换行
453+
- 出口 sweep 同时限制深度(64)与广度(10000),避免深嵌套 `data_json` 造成栈耗尽
377454
- 错误响应只回闭集内的固定码,非预期错误只写 stderr
378455

379-
### 6.6 校验器合规清单(`scripts/validate.mjs`)
456+
### 6.7 校验器合规清单(`scripts/validate.mjs`)
380457

381458
- [ ] 目录名 = `plugin.json.name`,符合 `PLUGIN_NAME`,≤64 字符
382459
- [ ] 全目录**无 symlink**

0 commit comments

Comments
 (0)