Skip to content

fix(serve): Allow approved external built-in text writes - #8852

Merged
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:agent/daemon-external-tool-writes
Aug 10, 2026
Merged

fix(serve): Allow approved external built-in text writes#8852
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:agent/daemon-external-tool-writes

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR lets daemon-owned same-host runtimes complete already-authorized external text writes from the built-in write_file, edit, notebook edit, and controlled shell sed edit paths without disabling ACP text-write delegation or widening HTTP and generic ACP workspace boundaries. Final built-in writes carry strict versioned internal origin metadata, and only daemon-owned adapters route valid outside-workspace writes to a guarded host writer. Workspace-internal writes continue through the existing workspace filesystem path.

The guarded external writer preserves runtime trust and generation checks, canonical-path locking, regular-file and symlink validation, atomic replacement, existing mode preservation or 0600 for new files, encoding metadata, the encoded 5 MiB limit, and one success or denial audit result.

Why it's needed

In same-host qwen serve, approving an external built-in text write only passed the core tool permission check. The final delegated ACP writeTextFile request was still rejected by the workspace filesystem with path_outside_workspace, so the built-in tool failed and the model could retry the same operation through shell. This closes that authorization loop while keeping delegated writes and the existing daemon filesystem safety boundary.

Reviewer Test Plan

How to verify

  • Start a same-host daemon with a workspace that does not contain the target file, ask write_file to write the external absolute path, and approve the exact built-in tool request. Expect the tool to complete, the target content to match exactly, and no shell call.
  • Reject the same request. Expect the tool to be canceled and the target file not to exist or change.
  • Repeat in YOLO mode. Expect no permission request and a successful exact write.
  • Send an unmarked ACP or HTTP write to an external path. Expect path_outside_workspace; also verify untrusted, stale-generation, oversized, special-file, and external leaf-symlink writes remain denied.
  • Confirm workspace-internal writes continue through the existing workspace filesystem path and preserve encoding, line endings, BOM, mode, atomic replacement, and audit behavior.

Evidence (Before & After)

Before: the deterministic real-daemon flow reported write_file as failed with path escapes workspace after allow_once; YOLO failed at the same final ACP write, and the target file was absent.

After: the deterministic real-daemon/fake-model test completes allow_once and YOLO writes with exact file readback and no shell fallback; reject_once issues no final write and leaves the target absent. Core tests pass 564/564, CLI tests pass 336/336, the targeted integration test passes, and full build and typecheck pass.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS, Node.js 24.12.0, npm workspace install with the repository Ink patch, locally built dist/cli.js, and a fake OpenAI server driving a real qwen serve/ACP child.

Risk & Scope

  • Main risk or tradeoff: incorrectly trusted origin metadata or path races could widen external writes; the implementation mitigates this with strict versioned parsing, daemon-owned opt-in only, fail-closed routing, canonical target validation, inode checks, shared path locking, trust and generation guards, and atomic replacement.
  • Not validated / out of scope: Windows and Linux E2E were not run locally; ordinary shell redirection remains a separate shell-permission path; pre-read memory pressure, external diff fan-out before approval, daemon local-read opt-out, and model retries after policy rejection remain follow-up concerns.
  • Breaking changes / migration notes: none. The new factory capability is optional, generic ACP and HTTP behavior is unchanged, and malformed or unsupported metadata follows the existing workspace-scoped path.

Linked Issues

Closes #8851

Related to #8618 and #8620.

中文说明

本 PR 做了什么

本 PR 允许 daemon 自建的同机 runtime 完成已经通过授权的外部文本写入,覆盖内置 write_file、edit、notebook edit 和受控 shell sed 编辑路径,同时不关闭 ACP 文本写入委托,也不放宽 HTTP 和通用 ACP 的 workspace 边界。内置工具的最终写入会携带严格、带版本的内部来源元数据,只有 daemon 自建 adapter 才会把带有效标记的 workspace 外写入路由到受控 host writer;workspace 内写入继续走现有 workspace filesystem 路径。

受控外部 writer 保留 runtime trust 与 generation 检查、canonical path 锁、普通文件与 symlink 校验、原子替换、已有文件 mode 保留或新文件使用 0600、编码元数据、编码后 5 MiB 上限,以及单个成功或拒绝审计结果。

为什么需要

在同机 qwen serve 中,批准外部内置文本写入只会通过 core 工具权限检查。最终委托的 ACP writeTextFile 请求仍会被 workspace filesystem 以 path_outside_workspace 拒绝,因此内置工具失败,模型还可能通过 shell 重试同一操作。本改动闭合该授权链路,同时保留委托写入和现有 daemon 文件系统安全边界。

Reviewer 测试计划

如何验证

  • 启动同机 daemon,选择一个不包含目标文件的 workspace,让 write_file 写入外部绝对路径,并批准准确的内置工具请求。预期工具完成、目标内容精确一致,且没有 shell 调用。
  • 拒绝相同请求。预期工具被取消,目标文件不会创建或修改。
  • 在 YOLO 模式重复。预期不产生 permission request,并成功精确写入。
  • 向外部路径发送无标记 ACP 或 HTTP 写入。预期返回 path_outside_workspace;同时验证 untrusted、过期 generation、超限、特殊文件和外部叶子 symlink 写入仍被拒绝。
  • 确认 workspace 内写入继续走现有 workspace filesystem 路径,并保留编码、换行、BOM、mode、原子替换和审计行为。

证据(改动前后)

改动前:确定性的真实 daemon 流程在 allow_once 后仍把 write_file 报告为失败,错误为 path escapes workspace;YOLO 也在同一个最终 ACP 写入处失败,目标文件不存在。

改动后:确定性的真实 daemon/fake-model 测试中,allow_once 和 YOLO 写入均完成,文件回读内容精确一致且没有 shell fallback;reject_once 不发起最终写入并保持目标不存在。Core 测试 564/564 通过,CLI 测试 336/336 通过,定向集成测试通过,完整 build 与 typecheck 通过。

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

macOS、Node.js 24.12.0、应用仓库 Ink patch 的 npm workspace install、本地构建的 dist/cli.js,以及由 fake OpenAI server 驱动的真实 qwen serve/ACP child。

风险与范围

  • 主要风险或取舍:如果错误信任来源元数据或路径竞态,可能扩大外部写入范围;实现通过严格带版本解析、仅 daemon 自建组件启用、fail-closed 路由、canonical target 校验、inode 检查、共享路径锁、trust 与 generation guard 以及原子替换进行缓解。
  • 未验证或不在范围内:本地未运行 Windows 和 Linux E2E;普通 shell 重定向仍是独立 shell 权限路径;预读内存压力、批准前外部 diff fan-out、daemon 本地读取 opt-out,以及策略拒绝后模型继续重试仍属于后续问题。
  • 破坏性变更或迁移说明:无。新增 factory 能力是可选的,通用 ACP 和 HTTP 行为不变,格式错误或不支持的元数据继续走现有 workspace 限定路径。

关联 Issue

关闭 #8851

关联 #8618#8620

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

E2E test report

Environment: macOS, Node.js 24.12.0, repository package 0.21.8, locally built dist/cli.js, fake OpenAI server, and a real qwen serve/ACP child. The external target was outside the test HOME, workspace, /tmp, and all managed read roots.

Baseline with the global 0.21.8 CLI:

  • allow_once emitted one permission request, then the final write_file call failed with path escapes workspace; the file was absent.
  • reject_once canceled the tool before the final write and left the file absent.
  • YOLO emitted no permission request but still failed at the delegated workspace boundary; the file was absent.

Verification with this PR:

  • allow_once completed the built-in write_file, produced exact sentinel content, and made no shell call.
  • reject_once sent no final write, reported the tool as canceled, and left the file absent.
  • YOLO emitted no permission request, completed the exact write, and made no shell call.
  • The targeted real-daemon integration test passed: 1 test passed, 5 unrelated tests skipped.
  • Core targeted tests passed 564/564; CLI targeted tests passed 336/336.
  • Full build and full workspace typecheck passed, along with targeted ESLint, Prettier, and git diff --check.

The deterministic assertions use tool completion status plus exact file readback, not model-input content, so the tool input itself cannot produce a false positive.

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 10, 2026
@doudouOUC
doudouOUC marked this pull request as ready for review August 10, 2026 08:33
@doudouOUC
doudouOUC enabled auto-merge August 10, 2026 08:33
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f6d7456, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed, not theoretical. This is the documented write-side follow-up to the already-merged read-side alignment (#8620, merged Aug 7 by the same author). The merged read-side design doc explicitly tracked this exact failure — "#8618 therefore still reproduces for the write_file, replace, and notebook_edit family" — and this PR removes that paragraph because it closes it. The PR body carries a before/after: allow_once approves the built-in tool, the final delegated ACP write is then refused with path_outside_workspace, and the model can fall back to shell. The linked issue #8851 (self-reported by the author, hence the review/self-reported label) matches that lineage.

Direction: aligned. Same-host qwen serve already aligned text reads with regular CLI permission semantics; leaving the write side broken keeps the authorization loop half-open (approve → fail at the last step → shell retry). CHANGELOG (claude-code): no direct reference to a daemon external-write exception, but the daemon/serve area is active and the internal-consistency argument stands on the merged read-side work. This touches the daemon workspace trust boundary, so it stays flagged for maintainer attention regardless of the automated verdict — and a maintainer has now reviewed and approved this exact commit (see Stage 3).

Size: core paths are touched (packages/core/src/services/**, packages/core/src/tools/**). Breakdown of the 1,551 changed lines across 31 files at this SHA: ~382 production logic, ~1,022 tests, ~139 docs, ~8 config (the new subpath export and its vitest alias) — under the 500-line maintainer-awareness threshold and the 1,000-line large-PR advisory, with a healthy test:code ratio.

Approach: the scope still feels right, and the two changes since the last review are exactly what the last review asked for. The marker helpers moved out of fileSystemService.ts into a dependency-light @qwen-code/qwen-code-core/toolWriteOrigin subpath (mirroring the existing memoryScopes precedent) to dissolve the serve fast-path import edge that tripped the bundle guard, and the branch was rebased onto current main with the docs/users/qwen-serve.md conflict reconciled by keeping both sides. No unrelated changes or drive-by refactors; every edit still serves the stated goal.

Risk: Stage 1e matched the same two high-risk paths as before — packages/cli/src/acp-integration/service/filesystem.ts (the acp-integration pattern) and packages/core/src/tools/shell.ts (the shell.ts pattern; here just a one-line origin tag). Both got focused Stage 2 review, and the CI evidence this escalation requires is now present on this commit.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 已观测到的真实问题,不是理论性加固。这是已合并的读侧对齐(#8620,同一作者,8 月 7 日合并)在写侧的文档化后续。已合并的读侧设计文档明确记录了这个失败——"#8618 therefore still reproduces for the write_file, replace, and notebook_edit family"——本 PR 正是删除该段并闭合它。PR 正文提供了 before/after:allow_once 批准内置工具后,最终委托的 ACP 写入仍被以 path_outside_workspace 拒绝,模型可能转而走 shell。关联 issue #8851(作者自报,因此带 review/self-reported 标签)与这条脉络一致。

方向: 对齐。同机 qwen serve 已经把文本对齐到常规 CLI 权限语义;写侧不闭合会让授权链路半开(批准 → 最后一步失败 → shell 重试)。CHANGELOG(claude-code):没有 daemon 外部写入例外的直接对应条目,但 daemon/serve 方向活跃,且内部一致性论点由已合并的读侧工作支撑。本改动触及 daemon workspace 信任边界,因此无论自动化结论如何都保持 maintainer 关注标记——而一位 maintainer 已经审查并批准了当前这个提交(见 Stage 3)。

规模: 触及核心路径(packages/core/src/services/**packages/core/src/tools/**)。本 SHA 下 31 个文件共 1,551 行改动拆分:约 382 行生产逻辑、约 1,022 行测试、约 139 行文档、约 8 行配置(新子路径导出及其 vitest 别名)——低于 500 行维护者关注阈值和 1,000 行大 PR 建议线,测试代码比健康。

方案: 范围依然合理,且上次审查以来的两处改动正是上次审查所要求的:标记 helper 从 fileSystemService.ts 挪到无依赖的 @qwen-code/qwen-code-core/toolWriteOrigin 子路径(沿用现有 memoryScopes 先例),以消除触发 bundle 守卫的 serve fast-path 导入边;分支 rebase 到当前 maindocs/users/qwen-serve.md 冲突以保留双方内容的方式解决。无无关改动或顺手重构;每处编辑仍服务于既定目标。

风险: Stage 1e 命中与上次相同的两个高风险路径——packages/cli/src/acp-integration/service/filesystem.tsacp-integration 模式)和 packages/core/src/tools/shell.tsshell.ts 模式;此处仅一行来源标记)。两者均在 Stage 2 重点审查,该升级所要求的 CI 证据现已存在于本提交。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at f6d74568c24a380fd9683dbc4ffe2353339f4ad0 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Code review

Re-reviewed the full diff at the rebased head. My independent proposal is unchanged from the previous pass — versioned in-band provenance attached only to final tool writes, strict fail-closed parsing, daemon-owned opt-in only, and a guarded host writer that re-validates every WFS invariant per request, rejecting the allowlist-at-grant-time alternative because of expiry/invalidation/race coupling — and the PR still matches it, including the details that exceeded it (inode-stability race test, BOM de-duplication across UTF-16, audit cardinality aligned with recordAndWrap's generation-closed carve-out).

The only code delta since the last review is the bundle-guard fix, and it is exactly the fix the review prescribed. The marker helpers left fileSystemService.ts — the module that drags glob and the file-write machinery into whatever imports it — for a new 56-line leaf module whose only import is type-only, exported through a ./toolWriteOrigin package subpath that mirrors the existing memoryScopes precedent, with the matching vitest alias and a barrel re-export so existing consumers are unaffected. The serve adapter now takes parseToolWriteOriginMeta through the subpath; that dissolves the edge that pulled the 5.45 MB chunk into the pre-listen fast path. The remainder of the delta is the rebase reconciliation in docs/users/qwen-serve.md. No security-relevant line moved.

The properties that matter for a trust-boundary change, re-verified on this SHA:

  • Marker security: parseToolWriteOriginMeta accepts exactly { version: 1, source } with a closed source set — extra keys, other versions, and non-objects fail closed. AcpFileSystemService deletes any caller-supplied marker before re-emitting one, and only does so from the trusted core toolWriteOrigin field — covered by tests on both sides.
  • Opt-in gating: the route opens only when allowSameHostToolWritesOutsideWorkspace is set, which happens solely in daemon-owned wiring (deps.fsFactory === undefined in server.ts/run-qwen-serve.ts, plus the daemon-created secondary/workspace runtimes). Two wiring tests prove an injected factory never inherits the exception even when it implements the host writer.
  • Writer invariants: absolute-path + suspicious-pattern checks, leaf-symlink rejection (dangling included), socket and directory rejection, dev/ino stability across canonicalization with a test that races a leaf swap, canonical-path locking on the shared daemon-wide registry, atomic temp+rename, mode preservation / 0600 for new files, the encoded-size cap applied after re-encoding, and the generation guard re-checked under the lock — all reusing existing WFS primitives, nothing duplicated. Exactly one audit row per outcome.
  • Workspace-internal writes (marked or not) keep the exact pre-PR path through writeTextOverwrite.

One standing non-blocking nit for the maintainer's awareness: AtomicWriteTextInput.target was widened from ResolvedPath to string so the host writer can pass its canonicalized target — a small type-safety concession, reasonable here, but worth a glance.

Flow

sequenceDiagram
    participant P1 as Built-in tool
    participant P2 as AcpFileSystemService
    participant P3 as Daemon bridge adapter
    participant P4 as WorkspaceFileSystem
    participant P5 as Guarded host writer
    P1->>P2: final writeTextFile with toolWriteOrigin
    P2->>P2: strip caller marker, add versioned marker
    P2->>P3: ACP writeTextFile
    alt workspace target
        P3->>P4: resolve then writeTextOverwrite (unchanged)
    else external, valid marker, daemon-owned opt-in
        P3->>P5: trust, generation, canonical lock, atomic rename
        P5-->>P3: one audit outcome
    else unmarked or malformed
        P3->>P4: ordinary route, fails closed
        P4-->>P3: path_outside_workspace
    end
Loading
Files changed (30 of 31 shown)
File What changed
docs/design/daemon-external-tool-text-writes.md New design doc owning the exception, threat model, and writer invariants
docs/design/daemon-local-text-reads.md Replaces the "still reproduces for writes" section with the closed-loop description
docs/developers/daemon/07-workspace-filesystem.md Boundary doc updated for the narrow marked-write exception
docs/users/qwen-serve.md User-facing bullet for the same-host write route and its limits
integration-tests/cli/qwen-serve-streaming.test.ts Real-daemon allow_once, reject_once, and YOLO flows asserting no shell fallback
packages/acp-bridge/README.md Notes the adapter exception is not a generic ACP capability
packages/acp-bridge/src/bridgeFileSystem.ts Interface contract docs: workspace-scoped by default, daemon-owned opt-in only
packages/cli/src/acp-integration/service/filesystem.test.ts Tests marker serialization and caller-marker stripping
packages/cli/src/acp-integration/service/filesystem.ts Strips caller-supplied marker, emits versioned marker only from the trusted core field
packages/cli/src/serve/bridge-file-system-adapter.test.ts Broad external-writer tests: symlinks, socket, inode race, encodings, size cap, generation
packages/cli/src/serve/bridge-file-system-adapter.ts Opt-in routing, strict meta parse, sanitized meta passthrough — now via the subpath import
packages/cli/src/serve/fs/index.ts Exports the new request type
packages/cli/src/serve/fs/workspace-file-system.ts Factory writeSameHostToolText plus the guarded external writer and target resolution
packages/cli/src/serve/run-qwen-serve.test.ts Wiring tests: daemon-owned enabled, injected factory disabled, generation closed
packages/cli/src/serve/run-qwen-serve.ts Enables the route only for daemon-created factories
packages/cli/src/serve/server-default-bridge-wiring.test.ts Default embedded wiring stays fail-closed for injected factories
packages/cli/src/serve/server.ts Same opt-in gate in createServeApp
packages/cli/vitest.config.ts Vitest alias for the new subpath, mirroring memoryScopes
packages/core/package.json The ./toolWriteOrigin subpath export
packages/core/src/index.ts Barrel re-export of the leaf module
packages/core/src/services/fileSystemService.ts CoreWriteTextFileRequest type carrying the core-only origin field
packages/core/src/services/tool-write-origin.test.ts Round-trip and malformed-marker rejection tests for the leaf module
packages/core/src/services/tool-write-origin.ts The dependency-light marker build/parse helpers (the bundle-guard fix)
packages/core/src/tools/edit.test.ts Asserts the edit origin tag reaches the final write
packages/core/src/tools/edit.ts Tags both final-write branches with the edit origin
packages/core/src/tools/notebook-edit.test.ts Asserts the notebook origin tag
packages/core/src/tools/notebook-edit.ts Tags the final notebook write
packages/core/src/tools/shell.ts Tags the simulated sed write
packages/core/src/tools/write-file.test.ts Asserts the write_file origin tag across encoding cases
packages/core/src/tools/write-file.ts Tags the final write with the write_file origin
…and 1 more file

Testing evidence — the PR's own CI at the reviewed commit (unattended run; no PR code executed here)

The previous pass's merge blocker is cleared on this commit. The required Test (ubuntu-latest, Node 22.x) check is green (20m17s), and its log shows the guard that used to fail now running to completion, followed by the unit suites that never started on the old SHA:

> node scripts/check-serve-fast-path-bundle.js
…
Startup bundle closure checks passed.
…
 Test Files  795 passed (795)
      Tests  18842 passed | 22 skipped (18864)

The red on the old SHA was this PR's own import edge; the green on this SHA is that edge removed. Serve A/B re-ran against the new head and saw no response changes across 4 scenarios; the real-daemon E2E and the SDK matrix are green. One honest gap in the CI profile: Integration Tests (CLI, No Sandbox) is skipped for this PR, so the new real-daemon allow_once/reject_once/YOLO integration test does not run in this PR's CI — a profile decision, not a PR defect, but it means the end-to-end behavioral claim currently rests on the unit/wiring tests above plus the author's local run.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success — includes check:serve-fast-path-bundle (red on the old SHA) and the full unit suite
Serve A/B (ubuntu-latest, Node 22.x) ✅ success — no response changes across 4 scenarios
Real daemon E2E / Java 11 ✅ success
precheck-pr / precheck ✅ success
authorize ✅ success
Classify PR ✅ success
label ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
SDK Java (ubuntu/macOS/windows, Java 11/17/21) ✅ success
Test (macos-latest / windows-latest, Node 22.x) ⏭️ skipped by workflow profile
Integration Tests (CLI, No Sandbox) ⏭️ skipped by workflow profile
review-pr (bot orchestration) ⏳ in progress — not part of the PR's CI

Sandboxed verification is already in flight on this head — the /verify lane was triggered by the same comment that started this re-run and its status marker reads running. It would settle, A/B against the base build, the claim the skipped integration profile cannot: that an approved write_file to an outside path actually commits with exact content and no shell fallback, while rejection leaves the target untouched and YOLO needs no second prompt.

Not verified: Windows/Linux behavior (author's "Tested on" table marks macOS only — the author's claim, not independently re-run; the unit suite itself ran green on ubuntu in CI), and the integration test in CI (skipped by profile — the in-flight /verify run covers it).

中文说明

代码审查

在 rebase 后的新 head 上重新审查了完整 diff。我的独立方案与上一轮一致(仅最终工具写入携带带版本带内来源标记、严格 fail-closed 解析、仅 daemon 自建启用、受控 host writer 每次请求重新校验全部 WFS 不变量;拒绝"授权时记录允许列表"的替代方案,因其引入过期/失效/竞态耦合)——PR 仍然与之吻合,包括超出我方案的细节(inode 稳定性竞态测试、UTF-16 的 BOM 去重、与 recordAndWrap generation-closed 豁免精确对齐的审计条数)。

上次审查以来唯一的代码差异就是 bundle 守卫修复本身,而且正是审查所开的方子。 标记 helper 离开了 fileSystemService.ts——那个会把 glob 和文件写入机制一并拖进来的模块——进入一个仅含 type-only 导入的 56 行叶子模块,经 ./toolWriteOrigin 包子路径导出(沿用现有 memoryScopes 先例),配套 vitest 别名与 barrel 再导出,既有消费者不受影响。serve adapter 现在经由子路径引入 parseToolWriteOriginMeta,消除了把 5.45 MB chunk 拖进 pre-listen fast path 的导入边。其余差异是 docs/users/qwen-serve.md 的 rebase 冲突调和。没有任何安全相关行发生变动。

对信任边界改动重要的性质,已在本 SHA 重新核验:

  • 标记安全parseToolWriteOriginMeta 只接受恰好 { version: 1, source } 且 source 属于封闭集合——多余键、其他版本、非对象一律 fail-closed。AcpFileSystemService 先删除调用方自带的标记,只从可信的 core toolWriteOrigin 字段重新签发——两侧均有测试覆盖。
  • 启用门控:仅当 allowSameHostToolWritesOutsideWorkspace 被设置时路由才打开,而它只出现在 daemon 自建接线中(server.ts/run-qwen-serve.tsdeps.fsFactory === undefined,以及 daemon 创建的 secondary/workspace runtime)。两个接线测试证明注入的 factory 即使实现了 host writer 也不会继承该例外。
  • writer 不变量:绝对路径 + 可疑路径检查、叶子 symlink 拒绝(含悬空)、socket 与目录拒绝、canonicalization 期间 dev/ino 稳定性(有换叶竞态测试)、共享 daemon 级 canonical path 锁、原子 temp+rename、mode 保留 / 新文件 0600、编码后尺寸上限、锁内复检 generation 守卫——全部复用现有 WFS 原语,无重复实现。每个结果恰好一条审计。
  • workspace 内写入(无论是否带标记)保持 PR 前的原路径 writeTextOverwrite

一个长期存在的非阻塞提醒:AtomicWriteTextInput.targetResolvedPath 放宽为 string 以便 host writer 传入 canonical 目标——小的类型安全让步,此处合理,但值得 maintainer 一瞥。

测试证据(本提交自带的 CI;无人值守运行,未执行任何 PR 代码)

上一轮的合并阻塞已在本提交解除。必需检查 Test (ubuntu-latest, Node 22.x) 转绿(20 分 17 秒),其日志显示原先失败的守卫现在完整运行并通过("Startup bundle closure checks passed."),随后是在旧 SHA 上从未启动的完整单元测试(例如最大一批 795 个测试文件 / 18,842 个测试全部通过)。旧 SHA 的红是本 PR 自己的导入边;本 SHA 的绿是这条边被移除。Serve A/B 在新 head 上重跑,4 个场景无响应变化;真实 daemon E2E 与 SDK 矩阵为绿。CI profile 中一个诚实的缺口:Integration Tests (CLI, No Sandbox) 在本 PR 被跳过,新增的真实 daemon allow_once/reject_once/YOLO 集成测试不在本 PR 的 CI 中运行——这是 profile 决定而非 PR 缺陷,但端到端行为主张目前由上述单元/接线测试加作者本地运行支撑。沙箱 /verify 已在同一触发评论下启动(状态标记为运行中),将以 A/B 对照 base 构建钉死该主张。未验证:Windows/Linux 行为(作者"测试平台"表仅 macOS——作者主张,未独立复跑)。

Qwen Code · qwen3.8-max

Reviewed at f6d74568c24a380fd9683dbc4ffe2353339f4ad0 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the previous pass's single blocker is fixed the exact way the review prescribed, the required CI including the bundle guard is green on this commit with the full suite running for the first time, and a fresh read of the diff surfaced nothing new; what remains is non-blocking nits.

Stepping back: this is the stronger half of a pair the project already committed to, and it now lands the way the last pass asked. The previous verdict said: fix the import edge, get check:serve-fast-path-bundle and the full suite green, and this is in good shape to come back. That is exactly what happened — the marker helpers moved behind a dependency-light subpath export (the alternative @wenshao suggested in the thread), the guard that caught the 5.45 MB chunk on the old SHA now reports Startup bundle closure checks passed. on this one, and the unit suites that died before starting on the old SHA ran to completion green. Serve A/B re-ran against the new head with no response drift, the real-daemon E2E and SDK matrix are green, and every pull_request CI run on this commit has settled.

What has not changed is my read of the code itself, because the code itself barely changed — and that read was already a favorable one. The negative tests carry the weight a trust-boundary change needs: injected factories don't inherit the exception even when they implement the host writer, untrusted runtimes and dangling symlinks fail with exactly one denial, and the approve/reject/YOLO flows assert no shell fallback. The design doc and the boundary docs were updated in the same diff, which is what I'd want to maintain against in six months. The previous pass also asked for two things to travel with any verdict: sandboxed verification and a maintainer's sign-off on the security model. The /verify run is in flight on this head, and a maintainer approved this exact commit while this re-run was in progress — the sign-off the last pass said it needed is now on record for this SHA.

The non-blocking items, named so they don't get lost: the integration profile skip means the real-daemon flow is pinned by the in-flight /verify run rather than this PR's CI; Windows and Linux E2E remain author-untested (the unit suite ran green on ubuntu); and the AtomicWriteTextInput.target widening is worth a maintainer's glance. None of these gate the merge.

Approving, pinned to the reviewed commit. ✅

中文说明

置信度:4/5 —— 上一轮唯一的阻塞项已按审查开出的方子精确修复,必需 CI(含 bundle 守卫)在本提交转绿、完整测试套件首次运行,重新通读 diff 未发现新问题;剩下的都是非阻塞提醒。

退一步看:这是项目已承诺的成对工作的写侧一半,且它以上一轮要求的方式落地。上一轮结论是:修掉导入边,让 check:serve-fast-path-bundle 与完整套件转绿,它就以良好状态回来。事实正是如此——标记 helper 挪到无依赖的子路径导出(即 @wenshao 在线程中建议的替代方案),在旧 SHA 上拦住 5.45 MB chunk 的守卫在本 SHA 报告"Startup bundle closure checks passed.",旧 SHA 上未能启动的单元测试套件完整跑完并全绿。Serve A/B 在新 head 上重跑无响应漂移,真实 daemon E2E 与 SDK 矩阵为绿,本提交的所有 pull_request CI 运行均已结束。

我对代码本身的判断没有变,因为代码本身几乎没变——而那个判断本来就是正面的。信任边界改动需要的分量由负面测试承担:注入 factory 即使实现 host writer 也不继承例外、不可信 runtime 与悬空 symlink 恰好一条拒绝审计、批准/拒绝/YOLO 流程断言无 shell 回退。设计文档与边界文档随同一 diff 更新——这是六个月后维护它时我会感谢作者的部分。上一轮还要求两件事与结论同行:沙箱验证与 maintainer 对安全模型的签核。/verify 运行已在本 head 上进行;一位 maintainer 在本轮重跑进行中批准了这个提交——上一轮说需要的签核已存在于本 SHA 的记录中。

非阻塞事项,点名以免遗失:integration profile 跳过意味着真实 daemon 流程由进行中的 /verify 运行而非本 PR 的 CI 钉死;Windows 与 Linux E2E 仍未经作者测试(单元测试已在 ubuntu 转绿);AtomicWriteTextInput.target 的放宽值得 maintainer 一瞥。这些都不构成合并门槛。

批准,锚定到已审查的提交。✅

Qwen Code · qwen3.8-max

Reviewed at f6d74568c24a380fd9683dbc4ffe2353339f4ad0 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design and test coverage are genuinely strong, but the required Test (ubuntu-latest, Node 22.x) check is red on this commit: the new value import of parseToolWriteOriginMeta from the @qwen-code/qwen-code-core barrel in packages/cli/src/serve/bridge-file-system-adapter.ts pulls the core tool-runtime chunk (5.45 MB, incl. glob/chokidar/@iarna/toml/fzf and shell.ts) into the serve pre-listen fast path, and check:serve-fast-path-bundle rejects it — see my Stage 2 comment for the traced mechanism and fix direction. The job failed before vitest started, so no CI test evidence exists for this commit yet. Once the import edge is fixed (leaf module or subpath export) and the suite is green — ideally with a @qwen-code /verify run pinning the allow_once/reject_once/YOLO behavior — this is in good shape to come back.

@wenshao

wenshao commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Diagnosed the Check serve fast-path bundle closure failure on this PR — it's a known failure class of that gate, and the trigger here is one line.

What the gate guards: qwen serve's fast startup path requires the pre-listen module closure to stay lean — no static reach into the settings machinery (glob, @iarna/toml, …), or cold start regresses. The gate's output shows the violation:

Serve fast-path bundle closure includes pre-listen runtime modules:
- glob vendor package
- @iarna/toml vendor package (×N)
  static path: run-qwen-serve-*.js -> chunk-LRZX6RV5.js (5,453,094 bytes)

The trigger in this PRpackages/cli/src/serve/bridge-file-system-adapter.ts:

import { parseToolWriteOriginMeta } from '@qwen-code/qwen-code-core';

That's a static top-level import of the core barrel, and bridge-file-system-adapter.ts sits in the serve pre-listen graph — so the whole 5.4 MB chunk (core + glob + toml) becomes statically reachable from run-qwen-serve for the sake of one small pure function.

The gate's established fix is a dynamic import at the use site:

const { parseToolWriteOriginMeta } = await import('@qwen-code/qwen-code-core');

parseToolWriteOriginMeta is only needed while handling a write request — long past listen — so the dynamic form costs nothing. (Alternative: export the function through a small core subpath module and import that statically, avoiding the barrel.)

You can reproduce locally with npm run check:serve-fast-path-bundle.

中文说明

诊断了本 PR 上 Check serve fast-path bundle closure 的失败——这是该门的已知失败类别,触发点就一行。

这个门守护什么:qwen serve 的快速启动要求**监听前(pre-listen)**的模块闭包保持精简——不能静态触达 settings 机器(glob@iarna/toml 等),否则冷启动退化。门的输出直接给出了违规链:pre-listen 根 run-qwen-serve 静态可达一个 5.4 MB 的 chunk。

本 PR 的触发点——packages/cli/src/serve/bridge-file-system-adapter.ts 顶部新增:

import { parseToolWriteOriginMeta } from '@qwen-code/qwen-code-core';

这是对 core **barrel(整包入口)**的静态导入,而该文件在 serve 的 pre-listen 图里——为取一个小纯函数,把整个 core(连带 glob、toml)静态拉进了启动闭包。

该门的标准修法是用点处动态导入(见上方代码)。parseToolWriteOriginMeta 只在处理写请求时才需要,那时早已过了 listen,动态导入无损。备选:把该函数经 core 的小子路径模块导出,静态导入子路径、绕开 barrel。

本地可用 npm run check:serve-fast-path-bundle 复现。


Handled with Claude Code (Opus 5, 1M context).

@doudouOUC doudouOUC self-assigned this Aug 10, 2026
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks — agreed and fixed in 6829bf2. The provenance helpers now live behind the dependency-light @qwen-code/qwen-code-core/toolWriteOrigin subpath, so the serve pre-listen graph no longer reaches the core barrel or the 5.45 MB tool-runtime chunk. On the current SHA, Test (ubuntu-latest, Node 22.x) (including check:serve-fast-path-bundle), real daemon E2E, Serve A/B, and the remaining required CI checks have passed. This addresses the old-SHA CHANGES_REQUESTED diagnosis and @wenshao’s suggested subpath alternative; no dynamic import was needed.

yiliang114
yiliang114 previously approved these changes Aug 10, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM after adversarial review at head 6829bf2. The authorization loop closes without widening any boundary: the four built-in tools attach a core-only toolWriteOrigin field only after the existing permission gate allows, AcpFileSystemService strips caller-supplied markers and serializes a strict versioned one, and the guarded host writer is reachable only from daemon-owned same-host adapters (option defaults off; injected factories verified never routed; HTTP/generic ACP paths never call writeSameHostToolText — single production call site). Marker parsing is fail-closed (exactly two own keys, version === 1 strict, 4-entry source allowlist, 7 negative cases), and the writer re-enforces trust, generation, suspicious-path, symlink/inode, size, and atomic-rename invariants under the canonical path mutex with exactly one audit outcome per path — symlink swap covered twice (inode stability during canonicalization plus fresh lstat inside the lock; final publish is rename, not write-through). The prior ci-bot blocker (fast-path bundle pulling a 5.45 MB chunk) is fixed via the dependency-light toolWriteOrigin subpath export and the bundle-gate test is green on head; Serve A/B reports zero response deltas.

P3 nits only, none blocking: toolWriteOrigin is a plain typed field on the public core request so a future in-process consumer could attach it with its own approval semantics (unexploitable across a boundary today — same-UID child already has shell and the daemon writer enforces all fs invariants — but worth a doc-level prohibition or unforgeable token); a marked write into another runtime's registered workspace bypasses that workspace's WFS context (documented tradeoff, shared lock serializes, current trust still enforced); audit success rows record canonical paths while denials record the original input; Windows/Linux untested by author (pre-existing WFS primitives, low risk). Ship it.

doudouOUC and others added 2 commits August 10, 2026 19:39
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (4bc75c2bdb) and resolved the docs/users/qwen-serve.md conflict by preserving both the expanded main-branch loader-environment hardening list and this PR’s updated read/write boundary documentation. New head: f6d74568c2. Verification after rebase: full build and workspace typecheck passed; core targeted tests 564/564; CLI targeted tests 325/325; serve fast-path bundle guard passed; Prettier and git diff --check passed. Two clean audit passes found no remaining conflict or semantic drift; range-diff shows only the expected documentation reconciliation.

@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@doudouOUC
doudouOUC requested a review from yiliang114 August 10, 2026 11:46
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 1085 passed · 0 failed · 1085 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:1085 通过 · 0 失败 · 1085 总计

Verification report

PR 8852 — fix(serve): Allow approved external built-in text writes

Verdict: merge-ready — 1085/1085 scripted assertions passed, 0 unexpected failures. Verified head: f6d74568c24a380fd9683dbc4ffe2353339f4ad0 (merge ba27e724d8 over base 4bc75c2bdb). Three low-severity, non-blocking observations filed below; none is a demonstrated defect of this PR.

中文摘要
  • 结论: merge-ready。1085 条脚本化断言全部通过,0 条意外失败。
  • A/B 结论: 中心声明被证实 load-bearing。真实 daemon + fake model 的 E2E 中,base 构建在 allow_once 批准外部 write_file 后目标文件始终不存在(ENOENT,3/3 次重试全红,即 issue fix(serve): allow approved built-in text writes outside daemon workspaces #8851 报告的 bug);head 构建 allow_once / reject_once / YOLO 三种模式全部按声明完成(精确回读、无 shell fallback、拒绝不写、YOLO 无二次确认)。adapter 层同样翻转:带标记的外部写入 base 报 path_outside_workspace,head 提交并审计一次 fs.access;workspace 内写入两臂均成功(阳性对照)。
  • 安全矩阵: 64/64。未标记/畸形/未来版本/未知 source/__proto__ 注入标记、默认关闭、factory 无能力、untrusted runtime、叶子 symlink(含悬垂)、socket、目录、超 5MiB 编码后大小、缺父目录、相对路径、可疑路径、generation 关闭、workspace 内经 symlink 外逃——全部 fail-closed 且每例恰好一条 fs.denied 审计;泛 ACP/HTTP 路径不读标记(workspace-file-write.ts、acp-http/dispatch.ts 直走 forRequest().resolve)。
  • 测试非空: 4 个单点变异(删 write_file 戳记、放宽标记解析、关停路由分支、停发 wire 标记)分别使 6 / 3 / 17 / 1 条测试按预期变红;未变异套件全绿(core 564/564,cli 445/445)。
  • Findings(均不阻塞): ① 未知 encoding 标签在新外部路径上静默回退 UTF-8 并记成功审计——系核心共享编码器既有语义(该 PR 未改动),同 UID 信任模型内无越界;② 设计文档称注入的 workspace registry 不继承例外,但 createServeApp 的条件是 deps.fsFactory === undefined,仅注入 registry 的嵌入方会启用例外(生产不可达,且仍处同机同 UID 模型内);③ canonical 校验与 rename 之间存在固有 TOCTOU 窗口,rename 不跟随目标 symlink 故无穿透写,属文档化设计边界。
  • 未覆盖: 逐 commit 归因(depth-2 浅克隆);base 臂 reject_once/YOLO(base E2E 在 allow_once 即红);Windows(作者已披露);全仓测试套件(仅定向文件);HTTP 路由套件未重跑(路由未被本 diff 触碰,边界已在 factory 层证明)。

Central claim and A/B

Central claim: an approved built-in text write (write_file / edit / notebook_edit / sed) whose target is outside the owning workspace completes through a guarded daemon host writer instead of failing path_outside_workspace at the final delegated ACP write, while unmarked/generic/HTTP/injected-factory writes keep the boundary.

Load-bearing proof — identical scenario, two builds (head bundle vs base bundle rebuilt in a scratch worktree whose @qwen-code/* links were re-pointed into the base tree; base dist proven clean: zero occurrences of qwen-code/tool-write-origin and writeSameHostToolText, head has both):

cell base 4bc75c2b head f6d74568
E2E: allow_once external write (real daemon, fake model) red — approved, then target never lands; ENOENT readback, 3/3 retries (04-e2e-base-approved-write-enoent.png) green — exact sentinel readback, tool_call_update completed (03-e2e-head-allow-reject-yolo-pass.png)
E2E: reject_once not reached (allow_once red aborts the test by design) green — no final write, target absent, tool failed
E2E: YOLO not reached green — no permission_request, exact write
E2E: shell fallback none ("toolName":"shell" absent from the session stream)
adapter: marked external write path_outside_workspace, target absent (02-base-control-cells.png) commits, mode 0600, exactly one fs.access audit (01-head-matrix-64-cells-all-green.png)
adapter: unmarked external write path_outside_workspace path_outside_workspace (boundary unchanged)
adapter: workspace-internal write (positive control, both arms) success success

Denial/acceptance matrix at head (head-matrix.mjs, mock-free against compiled dist): 64/64. Highlights: all four provenance sources accepted; malformed / future-version / unknown-source / string-version / array / null / JSON-__proto__ markers denied; option-off default and capability-less factory denied; untrusted → untrusted_workspace; leaf + dangling symlinks → symlink_escape with target untouched; socket/directory → parse_error; 6 MiB-encoded utf-16 → file_too_large while exactly-5 MiB UTF-8 accepted; missing parent → path_not_found; in-workspace symlink escaping outward is NOT routed external; closed generation rethrows the original error with one fs.denied(internal_error); every denial emits exactly one audit; UTF-16/UTF-8 BOM + CRLF byte-exact; ACP wire oracle: marker emitted with sessionId, caller-supplied marker stripped, trusted origin replaces a hostile caller marker.

Findings (non-blocking)

  1. Low — silent UTF-8 fallback for unknown encoding labels on the new external path. sanitizeWriteMeta passes encoding through as an arbitrary string; the shared core encoder (prepareTextFileContentCore, unchanged by this PR) silently falls back to UTF-8 when iconv-lite does not recognize the label, so a marked external write with a bogus encoding commits UTF-8 bytes and audits fs.access (success). Measured: matrix cell c24 + focused probe (writeText resolved, bytes 68656c6c6f for hello). Pre-existing semantics — the same fallback serves StandardFileSystemService (child-local mode) — and bounded by the same-UID principal model (the child already has equivalent filesystem authority through shell). Suggested only as an observability nicety: reject unrecognized labels in sanitizeWriteMeta so a degraded child surfaces fs.denied instead of a silently re-encoded success.

  2. Low — doc/code mismatch for injected workspace registries. The design doc says injected bridges, registries and factories "do not inherit the exception", but createServeApp's default-bridge condition is deps.fsFactory === undefined (server.ts:1013). An embedder that injects only workspaceRegistry (whose primary.routeFileSystemFactory is a real WFS factory) gets the exception enabled on that factory. Not reachable in production — runQwenServeImpl always passes fsFactory: routeFsFactory — and even in that edge the ACP child is same-host/same-UID, so the documented trust model still holds. The sentence is over-broad, not the code unsafe.

  3. Informational — residual TOCTOU between canonicalization and rename. resolveSameHostToolWriteTarget checks leaf symlink/inode identity before the canonical-path lock; a same-UID adversary could swap the leaf between check and rename. rename(2) does not follow a destination symlink (a swapped link is replaced, never written through), and a swap to another regular file only changes which file the approved path string names. The PR's own unit test covers the swap-during-canonicalization window; the post-lock window is inherent to the documented "same device and inode during resolution" design and bounded by the same-principal model.

Not covered

  • Per-commit attribution: depth-2 checkout exposes only the merge commit; the PR's two commits (9a00f424, f6d74568) are not individually reachable (git rev-list --count HEAD^1..HEAD^2 = 1, grafted). The aggregate HEAD^1..HEAD diff was verified; commit 2's stated purpose ("keep write provenance off startup bundle") was not separately exercised.
  • Base-arm reject_once / YOLO cells: the base E2E aborts at the allow_once red, so base behavior of the other two modes was not measured (they are unchanged-behavior cells; head covers them).
  • Windows: author-disclosed untested; this container is Linux.
  • Full-repo suites: only targeted files ran (core 564/564 across the six changed-file suites, cli 445/445 across the five). The HTTP file-route and acp-http suites were not re-run; the routes are untouched by this diff and their boundary was proven at the factory level (cell c22: forRequest().resolve(external,'write')path_outside_workspace; workspace-file-write.ts:224-229 and acp-http/dispatch.ts:4004-4019 use forRequest/resolve/writeTextOverwrite with no provenance parsing).
  • Live TOCTOU race not driven with a hostile same-UID process; argued from rename(2) semantics (finding 3).
  • The base worktree needed per-package node_modules symlinks (root-only resolution mis-resolves ajv/fdir/ignore/mime); dependencies are lockfile-identical so this is a clean control, and the base dist was identity-checked for the mechanism strings.

Methodology

Environment: node:22-bookworm container, refs/pull/8852/merge at depth 2; npm ci + npm run build pre-ran at head. Harnesses (head-matrix.mjs, base-cells.mjs in this artifact dir) import the compiled dist of packages/cli directly — real filesystem, real mutex/audit/generation plumbing, no mocks of the unit under test; the only test doubles are the ACP connection peer and the audit sink. Base control: scratch worktree at HEAD^1 with node_modules shim re-pointing every @qwen-code/* link into the base tree (realpath-asserted), full rebuild + bundle; base dist grepped clean of the mechanism. E2E arms run the PR's own integration test (-t 'closes approve/reject/YOLO') against each bundle with a fake OpenAI server driving a real qwen serve child. Mutation rounds edited the live tree, ran the pinning suite, and restored via git checkout -- (porcelain 0 after each). Raw logs in logs/; captures 01–05 in evidence/.

Evidence images

01-head-matrix-64-cells-all-green

02-base-control-cells

03-e2e-head-allow-reject-yolo-pass

04-e2e-base-approved-write-enoent

05-mutation-stamp-removed-tests-red

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after the rewrite: it is a clean rebase onto updated main whose final security-critical tree is byte-identical to the head I approved (6829bf2) — 14 of 15 security-critical files unchanged, the one differing file (run-qwen-serve.ts) differs only by upstream #8763, and the PR's own wiring is untouched. Re-read at f6d7456: marker strip + strict fail-closed parse, single production call site of writeSameHostToolText, adapter option default-off with the three daemon-owned wiring sites, trust/generation re-checks at entry/resolve/mutex, symlink/inode/size/atomic invariants, single audit outcome, and HTTP/generic ACP never routed — all preserved. The bundle extraction commit moves the provenance block into the dependency-light toolWriteOrigin subpath (type-only ACP import) and the required Test (ubuntu) check including the serve fast-path bundle closure step is green, so the prior 5.45MB-chunk blocker stays resolved. The four carried-over P3 nits (in-process origin field forgeable by future in-repo consumers, cross-runtime workspace tradeoff, audit path asymmetry, Windows unexercised) remain non-blocking. Nothing new found; ship it.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 10, 2026
Merged via the queue into QwenLM:main with commit fa8cae5 Aug 10, 2026
59 of 60 checks passed

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not explored to full depth (tool budget reached): chunk 3: did not execute the unit tests (worktree has no installed dependencies).; chunk 3: unit-test execution of filesystem.test.ts and bridge-file-system-adapter.test.ts (no installed deps in worktree).

中文说明

未探索到全部深度(达到工具调用预算):chunk 3:did not execute the unit tests (worktree has no installed dependencies).;chunk 3:unit-test execution of filesystem.test.ts and bridge-file-system-adapter.test.ts (no installed deps in worktree)

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +637 to +639
await fsp.writeFile(realTarget, 'original');
await fsp.symlink(realTarget, linkTarget);
const adapter = createBridgeFileSystemAdapter(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The new symlink tests reuse the file's existing symlink setup pattern but drop its Windows guard, so they fail during fixture setup on Windows without symlink privilege.

Failure scenario: on a Windows runner without Developer Mode or SeCreateSymbolicLinkPrivilege, fsp.symlink() rejects with EPERM before any adapter assertion runs, making required CI fail for an environment capability rather than product behavior. The pre-existing symlink test in this same file explicitly returns early on win32.

Suggested change
await fsp.writeFile(realTarget, 'original');
await fsp.symlink(realTarget, linkTarget);
const adapter = createBridgeFileSystemAdapter(
// Skip on Windows without symlink capability, as the existing symlink tests do:
if (process.platform === 'win32') return;

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +809 to +811
it('rejects content that exceeds the cap only after non-UTF-8 encoding', async () => {
const adapter = createBridgeFileSystemAdapter(
buildFactory({ trusted: true }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The external write path applies the byte cap to the UTF-8 length of the source string before the requested encoding is applied, so it rejects valid writes whose actually-published encoded size is within the limit.

Failure scenario (probe-verified): 1.8M characters measure 5,400,000 bytes as UTF-8 but 3,600,000 bytes as GBK. A marked external write with encoding: 'gbk' is rejected as file_too_large even though the bytes that would be published fit comfortably under the 5 MiB cap; the post-encoding check in writeEncodedTextTemp is the authoritative one.

Suggested change
it('rejects content that exceeds the cap only after non-UTF-8 encoding', async () => {
const adapter = createBridgeFileSystemAdapter(
buildFactory({ trusted: true }),
// Skip the UTF-8 precheck when a non-UTF-8 encoding is requested; the
// post-encoding enforcement in writeEncodedTextTemp is authoritative.

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +399 to +402
resolved = await resolveWithinWorkspace(
request.path,
workspaces.map((workspace) => workspace.path),
'write',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The same-host marked-write route resolves and probes the requested host path before checking whether the runtime is trusted, so an untrusted runtime's request can trigger host filesystem activity across the trust boundary.

Failure scenario: an untrusted runtime submits a provenance-marked write to an external absolute path; resolveWithinWorkspace calls realpath and walks existing ancestors before writeSameHostToolTextOutsideWorkspace reaches assertTrustedForIntent, which can expose permission/path distinctions or trigger automount/FUSE I/O for a request that should be rejected immediately.

Suggested change
resolved = await resolveWithinWorkspace(
request.path,
workspaces.map((workspace) => workspace.path),
'write',
// Assert trust at the top of writeSameHostToolText, inside the audited
// try/catch, before any filesystem resolution runs.

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +459 to +461
const target = await resolveSameHostToolWriteTarget(deps.request.path);
await deps.pathLocks.runExclusive(target, async () => {
deps.generationGuard?.assertOpen();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The external writer canonicalizes the target before acquiring an in-process-only lock, and publication still resolves through pathnames, leaving an ancestor symlink/junction replacement race that can redirect the write.

Failure scenario: after resolveSameHostToolWriteTarget resolves the parent, another same-host process can rename that directory and replace its pathname with a symlink or junction before the temporary file is created and renamed. The later checks validate the replacement directory and publish there, so bytes land outside the originally resolved directory while the audit records success against the stale canonical path.

Suggested change
const target = await resolveSameHostToolWriteTarget(deps.request.path);
await deps.pathLocks.runExclusive(target, async () => {
deps.generationGuard?.assertOpen();
// Bind temp-file creation and publication to an opened canonical parent
// handle (openat/renameat-style) with inode revalidation immediately before
// publish; or refuse external writes under replaceable parent directories.

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +467 to +470
const result = await atomicWriteTextResolvedFile({
target,
content,
mode: 'overwrite',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The external overwrite path replaces the target inode preserving the mode but not the uid/gid, so a successful write can revoke the original owner's access.

Failure scenario: a root-run daemon edits an existing service-account-owned 0600 file outside the workspace; the temporary inode is created as the daemon user, only the mode is copied, and the rename publishes a root-owned file the service account can no longer read — while the existing inline ACP writer explicitly restores uid/gid.

Suggested change
const result = await atomicWriteTextResolvedFile({
target,
content,
mode: 'overwrite',
// Snapshot the existing target's uid/gid with its mode and apply them to the
// temp handle before publication, best-effort where supported; otherwise
// reject replacing an inode whose ownership cannot be retained.

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +215 to +217
toolCalls: [
fakeToolCall('write_file', {
file_path: pendingExternalWritePath,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The real-daemon E2E exercises only write_file end to end, although the new external-write route is also documented for edit, notebook_edit, and the shell tool's simulated sed editor.

Failure scenario: a provenance or wiring regression specific to one of those three consumers (each is a separate final-write call site with its own toolWriteOrigin) passes the workspace suites and the write_file-only daemon scenario, so users still hit path_outside_workspace for a documented capability.

Suggested change
toolCalls: [
fakeToolCall('write_file', {
file_path: pendingExternalWritePath,
// Parameterize the daemon scenario over all four supported built-in write
// paths, at least proving a successful external commit for each.

— {{model}} via Qwen Code /review (v0.21.8)

events.some((event) => event.type === 'permission_request'),
).toBe(false);
}
expect(serializedEvents).not.toContain('"toolName":"shell"');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The "no shell fallback" assertion is vacuous because the fake model is hard-coded to return external write completed after the first tool result and can never request shell.

Failure scenario: if a production model retries a rejected or failed external write_file through shell, this test still passes — once any tool result exists, no shell call can ever be emitted — so the assertion does not verify the behavior its title claims.

Suggested change
expect(serializedEvents).not.toContain('"toolName":"shell"');
// Make the fake server request a shell fallback when the write result
// indicates failure, then assert the intended per-mode policy.

— {{model}} via Qwen Code /review (v0.21.8)

Comment on lines +511 to +513
it('keeps a marked workspace write on the existing WFS path', async () => {
const adapter = createBridgeFileSystemAdapter(
buildFactory({ trusted: true }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The "keeps a marked workspace write on the existing WFS path" test asserts only outcomes that are identical under both routing branches, so it cannot detect a rerouting regression.

Failure scenario (mutation-probe-verified): forcing writeSameHostToolText to always throw path_outside_workspace — simulating every marked write routed externally — leaves this test green. A real regression of that shape would silently lose existing-file encoding/BOM/line-ending preservation for marked workspace writes.

Suggested change
it('keeps a marked workspace write on the existing WFS path', async () => {
const adapter = createBridgeFileSystemAdapter(
buildFactory({ trusted: true }),
// Assert the external writer is NOT taken for a workspace target (e.g. pin
// encoding meta or audit shape that only the WFS branch preserves).

— {{model}} via Qwen Code /review (v0.21.8)

}

try {
await forRequest(ctx).writeTextOverwrite(resolved, request.content);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The in-workspace branch of writeSameHostToolText drops the sanitized encoding metadata, while the sibling outside-workspace branch honors it — the same marked tool call writes different bytes depending on where the target resolves.

Failure scenario (probe-verified): overwriting an existing GBK file with encoding: 'gbk' through a marked write produces corrupted bytes in-workspace (3f8d44) but the correct GBK bytes externally (c4e3bac3); on read failure (large/binary/EACCES) the in-workspace branch falls back to UTF-8/LF, breaking the very scenario #8618 targets.

Suggested change
await forRequest(ctx).writeTextOverwrite(resolved, request.content);
// Forward the sanitized request meta (bom/encoding/lineEnding) into
// writeTextOverwrite in the in-workspace branch, as the external branch does.

— {{model}} via Qwen Code /review (v0.21.8)

@wenshao

wenshao commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Local verification on a real stack — PR 8852

I rebuilt this PR and its merge base into two independent bundles and ran both against a real qwen serve daemon (real ACP child, real Web Shell UI, scripted OpenAI-compatible backend), plus a wire-level ACP probe. The behaviour change reproduces exactly as described, and every security boundary I could reach stayed closed.

Verdict: works as advertised, boundaries hold. LGTM from a verification standpoint. Three non-blocking notes for reviewers at the end.

Setup

Head f6d7456 (agent/daemon-external-tool-writes)
Base 4bc75c2 (merge base with main)
Build npm install && npm run bundle in each worktree → separate dist/cli.js + dist/web-shell
Runtime macOS 26.6 (Darwin 25.6.0), Node 24.18.1
Harness own fake OpenAI server + plain fetch/SSE daemon client (no repo test helpers), isolated HOME/QWEN_HOME per run
External fixture root /private/var/tmp/… — outside both the bound workspace and the /tmp local-read root

Bundle sanity: qwen-code/tool-write-origin is present in the head bundle chunks and absent from the base bundle, so the A/B really compares the two code paths.

A/B: the authorization loop actually closes

Same scripted model, same prompt, same approval action — only the bundle differs.

Scenario Base 4bc75c2 Head f6d7456
write_file to external abs path, allow_once failedpath escapes workspace completed, byte-exact content, no shell fallback
Same in YOLO failed at the final ACP write completed, 0 permission requests
reject_once ✅ no file ✅ no file, tool failed
edit tool on an external file (2nd origin) path escapes workspace completed, 0644 preserved
External overwrite of a CRLF + 0640 file path escapes workspace alpha\r\nGAMMA\r\n, mode still 0640
Workspace-internal write ✅ unchanged ✅ unchanged

New files land as 0600; existing files keep their mode (0640 / 0644 both verified). No shell tool call appeared in any event stream.

Screenshots — real Web Shell UI, identical user action

1. Head — the approval request for a path outside the bound workspace

head permission request

2. Head — after Yes, allow once: the tool completes and the external file is edited

head completed

On-disk readback: -rw------- 109 release-notes.md, content byte-identical to what the model sent.

3. Base — same prompt, same approval: Failed, and the target never exists

base failed

Security boundaries (all on head)

Check Result Denial source
External leaf symlink (after a successful read, so the write is actually attempted) ✅ denied, symlink + real target intact path is a symlink and cannot be overwritten — the new guarded writer
External FIFO ✅ denied, still a FIFO non-regular-file guard
Oversized (5 MiB + 1 KiB) external write ✅ denied, no file payload of 5243904 bytes exceeds write limit of 5242880 bytes
Untrusted workspace (DO_NOT_TRUST), tool request explicitly approved ✅ denied workspace is not trusted; write operations are forbidden (YOLO itself refused earlier with trust_gate)
HTTP POST /file/write to an external path 400 path_outside_workspace unchanged WFS boundary
Relative traversal path (../../../var/tmp/…) ✅ rejected at tool layer (File path must be absolute)
allow_once on write A, then write B in the same turn ✅ B prompts separately; rejecting B leaves B absent while A exists per-call permission not widened

Repo tests

  • PR's own integration test on the head bundle: passes (qwen serve — same-host external built-in text writes, 3.1 s). Copied verbatim onto the base bundle: fails at readFileSync(externalPath) → the test really guards this change.
  • Touched CLI unit files (adapter, WFS, run-qwen-serve, bridge wiring, ACP filesystem): 445/445 passed.
  • Touched core unit files (tool-write-origin, write-file, edit, notebook-edit): 202/202 passed.
  • npm run typecheck: clean. npm run check:serve-fast-path-bundle: Startup bundle closure checks passed. (confirms the last commit's fix).

Notes for reviewers (non-blocking)

1. The provenance marker is visible on the wire to third-party ACP clients. I ran the built CLI as a plain ACP agent (--experimental-acp) behind a minimal stdio client and dumped the fs/write_text_file params:

// head
"_meta": { "bom": false, "qwen-code/tool-write-origin": { "version": 1, "source": "write_file" } }
// base
"_meta": { "bom": false }

It is inert for a generic client (their own fs policy still decides), which matches the PR's framing. But an editor integration such as Zed now sees a new vendor _meta key on every built-in write. Worth an explicit line in packages/acp-bridge/README.md so integrators know it is informational and must not be treated as authorization.

2. The external writer does not recover existing-file metadata from disk. writeSameHostToolTextOutsideWorkspace calls mergeWriteMeta(undefined, request.meta ?? {}), while the workspace path (writeTextOverwrite) falls back to readExistingTextMeta. Today all four origins pass encoding/lineEnding/bom explicitly, so nothing regresses — I verified CRLF and mode survive an external overwrite. It is only a note that a future write path forgetting _meta would silently normalize an external file to UTF-8/LF instead of inheriting from disk, where the internal path would not.

3. The opt-in asymmetry in run-qwen-serve.ts is safe but reads oddly. The primary adapter gates on deps.fsFactory === undefined while the secondary and per-workspace runtimes pass true unconditionally. Those two always build their factory via resolveBridgeFsFactory with no injection point, and the adapter additionally requires factory.writeSameHostToolText to exist, so an injected factory fails closed either way. A short comment on the two unconditional sites would save the next reader the trace.

Residual risk already acknowledged in the PR description (approval→write path races on parent directories) is unchanged by this verification; I did not attempt to exercise it.

中文版本

PR 8852 本地真实环境验证

我把本 PR 与其 merge base 分别构建成两套独立 bundle,各自跑真实 qwen serve daemon(真实 ACP 子进程、真实 Web Shell UI、脚本化的 OpenAI 兼容后端),另外做了一次 ACP 线协议探针。行为变化与描述完全一致,我能触达的安全边界全部保持关闭。

结论:功能符合描述,边界稳固,从验证角度 LGTM。 文末有三条不阻塞的提示。

环境

Head f6d7456agent/daemon-external-tool-writes
Base 4bc75c2(与 main 的 merge base)
构建 各 worktree 内 npm install && npm run bundle → 独立 dist/cli.js + dist/web-shell
运行环境 macOS 26.6(Darwin 25.6.0)、Node 24.18.1
测试装置 自写 fake OpenAI server + 纯 fetch/SSE daemon 客户端(未使用仓库测试 helper),每次运行隔离 HOME/QWEN_HOME
外部 fixture 根目录 /private/var/tmp/… —— 同时位于 workspace 与 /tmp 本地读根之外

Bundle 校验:qwen-code/tool-write-origin 只出现在 head bundle chunk 中,base 中不存在,说明 A/B 确实在比较两条代码路径。

A/B:授权链路确实闭合

同一脚本化模型、同一 prompt、同一批准动作,只有 bundle 不同。

场景 Base 4bc75c2 Head f6d7456
write_file 写外部绝对路径,allow_once failed —— path escapes workspace completed,内容逐字节一致,无 shell fallback
同上,YOLO 模式 ❌ 在最终 ACP 写入处 failed completed,permission request 数为 0
reject_once ✅ 文件不存在 ✅ 文件不存在,工具 failed
edit 工具写外部文件(第二个 origin) path escapes workspace completed0644 保留
覆盖已存在的 CRLF + 0640 外部文件 path escapes workspace alpha\r\nGAMMA\r\n,mode 仍为 0640
workspace 内写入 ✅ 不变 ✅ 不变

新文件以 0600 落盘;已有文件保留原 mode(0640 / 0644 均已验证)。所有事件流中都没有出现 shell 工具调用。

截图 —— 真实 Web Shell UI,相同用户操作

1. Head —— 针对 workspace 外路径的批准请求

head permission request

2. Head —— 点击 Yes, allow once 后:工具完成,外部文件被写入

head completed

磁盘回读:-rw------- 109 release-notes.md,内容与模型发出的完全一致。

3. Base —— 同样的 prompt、同样的批准:Failed,目标文件始终不存在

base failed

安全边界(均在 head 上验证)

检查项 结果 拒绝来源
外部叶子 symlink(先成功 read,确保真正走到写入) ✅ 拒绝,symlink 与真实目标均未变 path is a symlink and cannot be overwritten —— 新的受控 writer
外部 FIFO ✅ 拒绝,仍是 FIFO 非普通文件校验
超限(5 MiB + 1 KiB)外部写入 ✅ 拒绝,无文件生成 payload of 5243904 bytes exceeds write limit of 5242880 bytes
未信任 workspace(DO_NOT_TRUST),且工具请求已被显式批准 ✅ 拒绝 workspace is not trusted; write operations are forbidden(YOLO 本身先被 trust_gate 拒绝)
HTTP POST /file/write 写外部路径 400 path_outside_workspace 现有 WFS 边界未变
相对路径穿越(../../../var/tmp/… ✅ 在工具层被拒(File path must be absolute
同一轮内先 allow_once 写 A,再写 B ✅ B 单独弹窗;拒绝 B 后 B 不存在而 A 存在 单次授权未被放大

仓库测试

  • PR 自带集成测试跑 head bundle:通过qwen serve — same-host external built-in text writes,3.1 秒)。原样拷到 base bundle 上:在 readFileSync(externalPath)失败 —— 说明该测试确实守住了本次改动。
  • 改动涉及的 CLI 单测文件(adapter、WFS、run-qwen-serve、bridge wiring、ACP filesystem):445/445 通过
  • 改动涉及的 core 单测文件(tool-write-origin、write-file、edit、notebook-edit):202/202 通过
  • npm run typecheck:干净。npm run check:serve-fast-path-bundleStartup bundle closure checks passed.(确认了最后一个 commit 的修复)。

给 reviewer 的提示(不阻塞合并)

1. provenance 标记对第三方 ACP 客户端是可见的。 我用最小 stdio 客户端拉起 --experimental-acp 模式的构建产物,dump 了 fs/write_text_file 参数:

// head
"_meta": { "bom": false, "qwen-code/tool-write-origin": { "version": 1, "source": "write_file" } }
// base
"_meta": { "bom": false }

对通用客户端它是惰性的(对方仍由自己的 fs 策略决定),这与 PR 的定位一致。但 Zed 等编辑器集成此后会在每次内置写入中看到一个新的厂商 _meta 字段。建议在 packages/acp-bridge/README.md 补一句,明确它只是信息性字段、不得当作授权凭据。

2. 外部 writer 不会从磁盘恢复已有文件的元数据。 writeSameHostToolTextOutsideWorkspace 调用的是 mergeWriteMeta(undefined, request.meta ?? {}),而 workspace 路径(writeTextOverwrite)会回退到 readExistingTextMeta。目前四个 origin 都显式传了 encoding/lineEnding/bom,因此没有回归 —— 我实测 CRLF 与 mode 在外部覆盖后均保留。这里只是提醒:将来若新增的写入路径漏传 _meta,外部文件会被静默归一化为 UTF-8/LF,而内部路径不会。

3. run-qwen-serve.ts 里的 opt-in 不对称虽然安全,但读起来容易困惑。 primary adapter 以 deps.fsFactory === undefined 作为开关,而 secondary 与 per-workspace runtime 直接传 true。后两者的 factory 始终由 resolveBridgeFsFactory 构造、没有注入点,且 adapter 还额外要求 factory.writeSameHostToolText 存在,因此注入型 factory 两侧都会 fail-closed。建议在这两处补一行注释,省去后来者的追踪成本。

PR 描述中已承认的遗留风险(批准到写入之间父目录的路径竞态)不在本次验证范围内,我没有尝试构造。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.9.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(serve): allow approved built-in text writes outside daemon workspaces

4 participants