tianshu-mcp 0.7.9 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -60,6 +60,44 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
60
60
 
61
61
  ---
62
62
 
63
+ ## [0.7.10] - 2026-10-06
64
+
65
+ ### Fixed
66
+
67
+ - **Precise semantics for the visual content-egress gate (issue #29)**: with `allowRemote` defaulting to
68
+ `false`, `<image:base64:file>` is rejected by the schema, while `<image:path>` / `<expect:file>` are
69
+ unconstrained — and the docs implied the contract layer blocked *every* way of handing an image to a
70
+ command, leading readers to believe the channels were sealed. Probes and counter-evidence show that
71
+ **gating the path channel yields zero security**: the judgement command runs with `shell:false` inside the
72
+ **project directory** and can already read project files itself; forcing an opt-in would also make users
73
+ set `allowRemote` just to pass the schema, which **opens up the inline base64 channel too**. This release
74
+ therefore does not change the gate — it states the boundary precisely and makes the channels visible.
75
+
76
+ ### Changed
77
+
78
+ - **New `contentChannelUsage()` (`src/visual/schema.ts`) as the single source of channel semantics**: the
79
+ gate check and the doctor/probe display share the same logic, so the two cannot drift apart.
80
+ - **`visual doctor` now reports each rule's actually-used placeholder channels and whether they are
81
+ constrained** (`GATED` / `NOT gated`) — surfacing what the command really receives (absence never raises
82
+ an alarm by itself).
83
+ - **`visual content probe` output gains `egressConstrained` / `pathChannels`** for programmatic checks.
84
+ - Wording synced across `SECURITY.md` / `SECURITY.en.md`, `docs/visual-acceptance.md` / `.en.md`, and
85
+ `skills/tianshu-mcp/SKILL.md`: `allowRemote` constrains **only** the inline-byte shape, and the contract
86
+ layer is **not** a complete block on image egress.
87
+ - `<expect:file>` nature clarified: it delivers a temp file holding the **expectation text** and is **not**
88
+ an image-egress channel (the issue conflated the two).
89
+
90
+ ### Verification
91
+
92
+ - 5 new channel/admission contract tests (`test/unit/visual-content-schema.test.ts`), 2 doctor
93
+ channel-semantics tests (`test/unit/visual-runtime.test.ts`), plus the probe field assertion.
94
+ - Counter-evidence probe: the command read a project file with **zero placeholders**
95
+ (`READ 4187 bytes with zero placeholders`), proving gating the path channel has no benefit.
96
+ - Full `npm test` **1582 passed / 12 skipped, 0 failed**; `typecheck` / `lint` / `build` / `pack:check` /
97
+ `check:stdio` all green.
98
+
99
+ ---
100
+
63
101
  ## [0.7.9] - 2026-10-06
64
102
 
65
103
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -58,6 +58,39 @@
58
58
 
59
59
  ---
60
60
 
61
+ ## [0.7.10] - 2026-10-06
62
+
63
+ ### 修复
64
+
65
+ - **视觉内容判定的外发闸门语义精确化(issue #29)**:`allowRemote` 默认 `false` 时,
66
+ `<image:base64:file>` 被 schema 拒绝,但 `<image:path>` / `<expect:file>` 不受任何约束——
67
+ 文档措辞又暗示「契约层封死了所有把图交给命令的形态」,读者会以为通道已被堵死。
68
+ 经探针实测与反证(见下),**门控路径通道零安全收益**:判定命令以 `shell:false` 在**项目目录内**
69
+ 执行,本就能自读项目内文件;且强制放行会因「必须开 `allowRemote` 才能过 schema」而**反向连带
70
+ 放行 base64 内联**。故本版不改门控、只把边界说清并把通道可见化。
71
+
72
+ ### 变更
73
+
74
+ - **新增 `contentChannelUsage()`(`src/visual/schema.ts`)作为通道语义的单一来源**:
75
+ 门控判定与 doctor/probe 展示共用同一段逻辑,避免两处实现分叉。
76
+ - **`visual doctor` 逐规则输出实际使用的占位符通道与是否受约束**(`GATED` / `NOT gated`)——
77
+ 把「命令实际拿到什么」摆到台面(缺席不会自己报警)。
78
+ - **`visual content probe` 输出新增 `egressConstrained` / `pathChannels` 字段**,便于调用方可编程核对。
79
+ - `SECURITY.md` / `SECURITY.en.md`、`docs/visual-acceptance.md` / `.en.md`、`skills/tianshu-mcp/SKILL.md`
80
+ 同步措辞:明确 `allowRemote` **只**约束内联字节形态,契约层**不是**对图片外发的完备拦截。
81
+ - `<expect:file>` 性质澄清:它交付的是**期望文本**临时文件,**不是图片外发通道**(issue 原文在此处有误归并)。
82
+
83
+ ### 验证
84
+
85
+ - 新增 5 个 `contentChannelUsage` / 准入边界契约用例(`test/unit/visual-content-schema.test.ts`),
86
+ 2 个 doctor 通道语义用例(`test/unit/visual-runtime.test.ts`),probe 字段断言同步。
87
+ - 反证探针实测:命令在**零占位符**下即读到项目内文件字节(`READ 4187 bytes with zero placeholders`),
88
+ 证明门控路径通道无收益。
89
+ - 全量 `npm test` **1582 passed / 12 skipped,0 失败**;`typecheck` / `lint` / `build` / `pack:check` /
90
+ `check:stdio` 全绿。
91
+
92
+ ---
93
+
61
94
  ## [0.7.9] - 2026-10-06
62
95
 
63
96
  ### 修复
@@ -4,4 +4,4 @@
4
4
  * 本文件由 scripts/sync-version.mjs 在每次 build 前重新生成。
5
5
  */
6
6
  // generated: 勿手改 —— 运行 `npm run build` 自动同步
7
- export const MCP_SERVER_VERSION = "0.7.9";
7
+ export const MCP_SERVER_VERSION = "0.7.10";
@@ -15,6 +15,7 @@ import { VisualBrowser } from "./capture.js";
15
15
  import { VisualServices } from "./services.js";
16
16
  import { clearContentCache } from "./content-cache.js";
17
17
  import { resolveCommandPath } from "./content-command.js";
18
+ import { contentChannelUsage } from "./schema.js";
18
19
  import { contentCommandParts, hasContentRules, probeContent, } from "./content.js";
19
20
  import { isDefaultWorkspace } from "../tasks/task.js";
20
21
  function taskDirectory(home, taskId) {
@@ -159,6 +160,7 @@ export async function probeContentRules(home, projectPath, ruleId) {
159
160
  command: effective.command,
160
161
  resolved: (await resolveCommandPath(effective.command, await projectFile(projectPath, effective.cwd))) !== null,
161
162
  allowRemote: effective.allowRemote,
163
+ ...contentChannelUsage(effective.argsTemplate),
162
164
  });
163
165
  for (const [fileIndex, file] of rule.files.entries())
164
166
  results.push(await probeContent({ config, project: projectPath, budget, tempDir: tempRoot }, {
@@ -175,6 +177,7 @@ export async function probeContentRules(home, projectPath, ruleId) {
175
177
  command: effective.command,
176
178
  resolved: (await resolveCommandPath(effective.command, await projectFile(projectPath, effective.cwd))) !== null,
177
179
  allowRemote: effective.allowRemote,
180
+ ...contentChannelUsage(effective.argsTemplate),
178
181
  });
179
182
  for (const viewport of config.viewports.filter((v) => page.viewports === undefined || page.viewports.includes(v.id))) {
180
183
  const captured = await browser.capture(projectPath, page, viewport, await services.get(page.source));
@@ -5,6 +5,7 @@ import { VisualError } from "./errors.js";
5
5
  import { projectFile } from "./paths.js";
6
6
  import { contentCommandParts, hasContentRules, contentRulesOf } from "./content.js";
7
7
  import { resolveCommandPath } from "./content-command.js";
8
+ import { contentChannelUsage } from "./schema.js";
8
9
  export function assertVisualRuntime(version = process.versions.node) {
9
10
  const [major = 0, minor = 0] = version.split(".").map(Number);
10
11
  if (major < 20 || (major === 20 && minor < 3))
@@ -117,6 +118,8 @@ export async function doctor(projectPath, home) {
117
118
  await check("browser", async () => resolveBrowser((await readAcceptanceConfig(projectPath))?.visual?.browser ?? { mode: "managed" }, home));
118
119
  // 内容校验诊断(issue #13 F 组):逐条有效命令的解析结果 + allowRemote 声明清单、
119
120
  // 规则数 × samples × timeoutMs 与 roundTimeoutMs 的预算对比(超预算给出建议值,不自动改配置)
121
+ // issue #29:额外显性化**通道语义**——命令实际使用哪些占位符、哪些受 allowRemote 约束,
122
+ // 避免「allowRemote=false 即封死一切外发」的错误安全感(缺席不会自己报警)。
120
123
  await check("content command", async () => {
121
124
  const visual = (await readAcceptanceConfig(projectPath))?.visual;
122
125
  if (!visual?.content.enabled || !hasContentRules(visual))
@@ -129,7 +132,11 @@ export async function doctor(projectPath, home) {
129
132
  const resolved = await resolveCommandPath(effective.command, cwd);
130
133
  if (!resolved)
131
134
  unresolved.push(rule.label);
132
- lines.push(`${rule.label}: ${effective.command} -> ${resolved ?? "UNRESOLVED (will block the whole round)"}; allowRemote=${effective.allowRemote}`);
135
+ // allowRemote 的实际约束范围只有内联字节通道;路径通道不受约束(issue #29)
136
+ const channels = contentChannelUsage(effective.argsTemplate);
137
+ const gated = channels.egressConstrained ? "GATED" : "NOT gated";
138
+ const used = [...(channels.egressConstrained ? ["<image:base64:file>"] : []), ...channels.pathChannels];
139
+ lines.push(`${rule.label}: ${effective.command} -> ${resolved ?? "UNRESOLVED (will block the whole round)"}; allowRemote=${effective.allowRemote} (constrains only <image:base64:file>); channels used: ${used.length ? used.join(", ") : "(none)"} — ${gated} by allowRemote`);
133
140
  }
134
141
  // 有效命令不可解析会让整轮配置错误(assertContentReady 抛错),诊断必须据实报失败
135
142
  if (unresolved.length)
@@ -132,6 +132,29 @@ export const ContentVerdictSchema = z
132
132
  const envReference = z.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.string().min(1));
133
133
  /** 占位符白名单:argsTemplate 中形如 <...> 的 token 只允许这三个 */
134
134
  export const CONTENT_PLACEHOLDERS = ["<image:path>", "<expect:file>", "<image:base64:file>"];
135
+ /**
136
+ * 外发闸门的通道边界(issue #29)——**唯一的语义源**,schema 校验与 doctor/probe 展示共用:
137
+ * - `<image:base64:file>`:把图片字节**内联**进临时文件。这是「明确要把图片交给命令」的强信号,
138
+ * 受 `allowRemote` 约束(未放行即拒绝配置)。定位是**防无意/防误配**,不是防有意外发。
139
+ * - `<image:path>` / `<expect:file>`:交付路径/期望文本,**不受** `allowRemote` 约束。
140
+ * 命令的 cwd 本就在项目内、可自读文件,门控 path 无安全收益(见计划 §2.1 探针);
141
+ * 且强制放行会反向扩大外发面(用户为过 schema 而开 allowRemote 会连带放行 base64)。
142
+ */
143
+ export const EGRESS_CONSTRAINED_PLACEHOLDER = "<image:base64:file>";
144
+ /** 按 argsTemplate 统计通道使用情况:doctor/probe 据此把「命令实际拿到什么」显性化 */
145
+ export function contentChannelUsage(argsTemplate) {
146
+ const pathChannels = [];
147
+ let egressConstrained = false;
148
+ for (const token of argsTemplate)
149
+ for (const match of token.matchAll(/<[^<>\s]*>/g)) {
150
+ const channel = match[0];
151
+ if (channel === EGRESS_CONSTRAINED_PLACEHOLDER)
152
+ egressConstrained = true;
153
+ else if (!pathChannels.includes(channel))
154
+ pathChannels.push(channel);
155
+ }
156
+ return { egressConstrained, pathChannels };
157
+ }
135
158
  /** 页面/规则共用的内容检查声明(pages[].content 与 contents[] 条目) */
136
159
  export const ContentCheckSchema = z
137
160
  .object({
@@ -296,9 +319,11 @@ export const VisualConfigSchema = z
296
319
  const argsTemplate = check.argsTemplate ?? v.content.argsTemplate;
297
320
  if (!command || !argsTemplate)
298
321
  issue(`${label}: content checks require command and argsTemplate`);
299
- // 外发闸门:字节外传占位符必须逐规则显式放行
322
+ // 外发闸门:仅内联字节通道(<image:base64:file>)需逐规则显式放行;
323
+ // 路径通道(<image:path>/<expect:file>)不受约束——边界见 EGRESS_CONSTRAINED_PLACEHOLDER 注释
300
324
  const allowRemote = check.allowRemote ?? v.content.allowRemote;
301
- if (argsTemplate?.some((token) => token.includes("<image:base64:file>")) && allowRemote !== true)
325
+ const channels = contentChannelUsage(argsTemplate ?? []);
326
+ if (channels.egressConstrained && allowRemote !== true)
302
327
  issue(`${label}: <image:base64:file> requires allowRemote = true`);
303
328
  // 预算自洽:roundTimeoutMs 是硬总闸,超预算配置必然整轮 ROUND_TIMEOUT,必须在配置期拦截
304
329
  const samples = check.samples ?? v.content.samples;
@@ -166,13 +166,15 @@ client. Judgement is fully delegated to a local command you supply, which uses i
166
166
 
167
167
  - **Placeholders** (a placeholder absent from the template produces no temporary file):
168
168
 
169
- | Placeholder | Expands to | Extra condition |
170
- |---|---|---|
171
- | `<image:path>` | absolute path of the inspected image (through the project path gate) | — |
172
- | `<expect:file>` | absolute path of a temporary file holding the expectation as UTF-8 | — |
173
- | `<image:base64:file>` | absolute path of a temporary file holding the image's base64 | **requires** the rule's effective `allowRemote === true`, else the schema rejects it |
169
+ | Placeholder | Expands to | Constrained by `allowRemote`? | Extra condition |
170
+ |---|---|---|---|
171
+ | `<image:path>` | absolute path of the inspected image (through the project path gate) | **No** | — |
172
+ | `<expect:file>` | absolute path of a temporary file holding the expectation as UTF-8 (**not** an image-egress channel; it carries expectation text only) | **No** | — |
173
+ | `<image:base64:file>` | absolute path of a temporary file holding the image's base64 (inlines the image **bytes**) | **Yes** | **requires** the rule's effective `allowRemote === true`, else the schema rejects it |
174
174
 
175
- Any other `<...>` token is rejected at configuration time.
175
+ Any other `<...>` token is rejected at configuration time. `allowRemote` constrains **only** the
176
+ inline-byte shape `<image:base64:file>`; the two path channels are unconstrained (see “Egress statement”
177
+ below), and `visual doctor` marks each rule's actually-used channels.
176
178
 
177
179
  - **stdout**: the **last non-empty line** is parsed as JSON: `{ "passed": boolean, "confidence"?: 0..1, "reason": string }` (strict mode; unknown fields rejected).
178
180
  - **Exit code**: `0` means the command ran normally (**not** that the judgement passed — read the JSON); non-`0` means the command failed.
@@ -221,7 +223,9 @@ There is no separate cap on judgement count or spend. Cost is bounded entirely b
221
223
 
222
224
  ### Egress statement
223
225
 
224
- Whether images leave the machine **depends on the behaviour of your command**; the MCP cannot block that at the system level. Its enforcement is contract-level only: a rule that has not explicitly opted into `allowRemote` may not use `<image:base64:file>` (the schema rejects it). `visual doctor` lists each rule's `allowRemote` declaration. Confirm your command's actual behaviour yourself.
226
+ Whether images leave the machine **depends on the behaviour of your command**; the MCP cannot block that at the system level. Its enforcement is contract-level only, and **covers just the `allowRemote` constraint on `<image:base64:file>`** (inlining the image bytes into a temp file; the schema rejects it unless the rule explicitly opts in).
227
+
228
+ **The contract layer is not a complete block on image egress** (issue #29): `<image:path>` / `<expect:file>` are **not** constrained by `allowRemote`. The judgement command runs with `shell:false` inside the **project directory** and can read the inspected project image by itself, so gating the path channel adds no security while forcing users to set `allowRemote` just to pass the schema — which would also open up the inline base64 channel. `allowRemote` is about **preventing accidental/misconfigured inline egress**, not “blocking every way of handing an image to a command”. `visual doctor` lists each rule's `allowRemote` declaration and the placeholder channels it **actually uses** (marking which are constrained). Confirm your command's actual behaviour yourself.
225
229
 
226
230
  ### Testing your command
227
231
 
@@ -168,13 +168,14 @@ tianshu-mcp visual rules approve TASK_ID REVIEW_ID DIGEST "用户确认的批准
168
168
 
169
169
  - **占位符**(未在模板中出现的占位符不会生成对应临时文件):
170
170
 
171
- | 占位符 | 展开为 | 附加条件 |
172
- |---|---|---|
173
- | `<image:path>` | 被检图片的绝对路径(经项目路径闸门) | — |
174
- | `<expect:file>` | 写入 UTF-8 期望原文的临时文件绝对路径 | — |
175
- | `<image:base64:file>` | 写入该图片 base64 的临时文件绝对路径 | **必须**该规则有效 `allowRemote === true`,否则 schema 拒绝 |
171
+ | 占位符 | 展开为 | 是否受 `allowRemote` 约束 | 附加条件 |
172
+ |---|---|---|---|
173
+ | `<image:path>` | 被检图片的绝对路径(经项目路径闸门) | **否** | — |
174
+ | `<expect:file>` | 写入 UTF-8 期望原文的临时文件绝对路径(**不是图片外发通道**,只交付期望文本) | **否** | — |
175
+ | `<image:base64:file>` | 写入该图片 base64 的临时文件绝对路径(把图片**字节**内联进文件) | **是** | **必须**该规则有效 `allowRemote === true`,否则 schema 拒绝 |
176
176
 
177
- 出现任何其他 `<...>` token 直接拒绝配置。
177
+ 出现任何其他 `<...>` token 直接拒绝配置。`allowRemote` **只**约束 `<image:base64:file>` 这一内联字节
178
+ 形态;两个路径通道不受约束(原因见下方「数据外发声明」),`visual doctor` 会逐规则标出实际使用的通道。
178
179
 
179
180
  - **stdout**:取**最后一行非空文本**解析 JSON:`{ "passed": boolean, "confidence"?: 0..1, "reason": string }`(严格模式,未知字段拒绝)。
180
181
  - **退出码**:`0` 表示命令正常执行(**不代表判定通过**,通过与否看 JSON);非 `0` 表示命令执行失败。
@@ -230,9 +231,14 @@ tianshu-mcp visual rules approve TASK_ID REVIEW_ID DIGEST "用户确认的批准
230
231
 
231
232
  ### 数据外发声明
232
233
 
233
- 图片是否离开本机取决于**用户自备命令的行为**,MCP 无法在系统层拦截。MCP 的强制力仅在契约层:未显式放行
234
- `allowRemote` 的规则禁止使用 `<image:base64:file>`(schema 拒绝)。`visual doctor` 列出各规则的 `allowRemote`
235
- 声明。请自行确认命令的实际行为。
234
+ 图片是否离开本机取决于**用户自备命令的行为**,MCP 无法在系统层拦截。MCP 的强制力仅在契约层,且
235
+ **只覆盖 `allowRemote` 对 `<image:base64:file>` 的约束**(把图片字节内联进临时文件;未显式放行即 schema 拒绝)。
236
+
237
+ **契约层不是对图片外发的完备拦截**(issue #29):`<image:path>` / `<expect:file>` 不受 `allowRemote`
238
+ 约束。判定命令以 `shell:false` 在**项目目录内**执行,本身就能读取项目内的被检图片,因此门控路径通道
239
+ 既无安全收益,又会因「必须开 `allowRemote` 才能过 schema」而反向连带放行 base64 内联。`allowRemote`
240
+ 的定位是**防无意/防误配的内联外发**,不是「封死一切把图交给命令的形态」。`visual doctor` 列出各规则的
241
+ `allowRemote` 声明与**实际使用的占位符通道**(标明哪些通道受约束)。请自行确认命令的实际行为。
236
242
 
237
243
  ### 验证自备命令
238
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tianshu-mcp",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "description": "天枢 × AI-Agent 编排 MCP server —— 驱动 Codex、TraeWork、ZCode、Kimi Code、Qoder CN 与 Open Design 完成项目开发、验收、失败返修与再验收闭环。",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -274,7 +274,7 @@ meta 的 `needsUserKind` 给出等待类型,`pendingQuestion` 给出问题原
274
274
  - **整轮阻塞**:任一规则的**有效**命令不可解析(`CONTENT_COMMAND_MISSING`)或宿主环境变量缺失(`CONTENT_ENV_MISSING`)会让整轮进 `needs_attention` 且**不产出任何视觉结果行**。这是 fail-closed,不是告警。
275
275
  - **`uncertain` 不是失败**:票不集中或低于 `minConfidence` 时判 `uncertain`,永不阻塞、不触发返修;命令不报 confidence 时 `minConfidence` 不生效。
276
276
  - 排查:`tianshu-mcp visual doctor <project>`、`visual content probe <project> [ruleId]`、`visual content cache clear <taskId>`。
277
- - **数据外发**:`allowRemote` 默认 `false`,未放行的规则禁止使用字节外传占位符;图片是否离开本机取决于用户命令的行为,MCP 无法在系统层拦截。
277
+ - **数据外发**:`allowRemote` 默认 `false`,**只**禁止受约束的内联字节通道 `<image:base64:file>`(未放行即 schema 拒绝);`<image:path>` / `<expect:file>` 是**不受约束**的路径通道(命令在项目内执行、本就能自读文件,门控无收益)。契约层不是对图片外发的完备拦截——图片是否离开本机取决于用户命令的行为,MCP 无法在系统层拦截。`visual doctor` 逐规则标出实际使用的通道与是否 `GATED`。
278
278
 
279
279
  ---
280
280