Repository navigation
fix(serve): Allow approved external built-in text writes - #8852
Conversation
E2E test reportEnvironment: macOS, Node.js 24.12.0, repository package 0.21.8, locally built Baseline with the global 0.21.8 CLI:
Verification with this PR:
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. |
|
Diagnosed the What the gate guards: The trigger in this PR — import { parseToolWriteOriginMeta } from '@qwen-code/qwen-code-core';That's a static top-level import of the core barrel, and The gate's established fix is a dynamic import at the use site: const { parseToolWriteOriginMeta } = await import('@qwen-code/qwen-code-core');
You can reproduce locally with 中文说明诊断了本 PR 上 这个门守护什么: 本 PR 的触发点—— import { parseToolWriteOriginMeta } from '@qwen-code/qwen-code-core';这是对 core **barrel(整包入口)**的静态导入,而该文件在 serve 的 pre-listen 图里——为取一个小纯函数,把整个 core(连带 glob、toml)静态拉进了启动闭包。 该门的标准修法是用点处动态导入(见上方代码)。 本地可用 Handled with Claude Code (Opus 5, 1M context). |
|
Thanks — agreed and fixed in |
yiliang114
left a comment
There was a problem hiding this comment.
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.
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
6829bf2 to
f6d7456
Compare
|
Rebased onto current |
|
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)为单个提交。 |
|
@qwen-code /triage |
yiliang114
left a comment
There was a problem hiding this comment.
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.
wenshao
left a comment
There was a problem hiding this comment.
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)
| await fsp.writeFile(realTarget, 'original'); | ||
| await fsp.symlink(realTarget, linkTarget); | ||
| const adapter = createBridgeFileSystemAdapter( |
There was a problem hiding this comment.
[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.
| 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)
| it('rejects content that exceeds the cap only after non-UTF-8 encoding', async () => { | ||
| const adapter = createBridgeFileSystemAdapter( | ||
| buildFactory({ trusted: true }), |
There was a problem hiding this comment.
[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.
| 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)
| resolved = await resolveWithinWorkspace( | ||
| request.path, | ||
| workspaces.map((workspace) => workspace.path), | ||
| 'write', |
There was a problem hiding this comment.
[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.
| 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)
| const target = await resolveSameHostToolWriteTarget(deps.request.path); | ||
| await deps.pathLocks.runExclusive(target, async () => { | ||
| deps.generationGuard?.assertOpen(); |
There was a problem hiding this comment.
[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.
| 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)
| const result = await atomicWriteTextResolvedFile({ | ||
| target, | ||
| content, | ||
| mode: 'overwrite', |
There was a problem hiding this comment.
[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.
| 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)
| toolCalls: [ | ||
| fakeToolCall('write_file', { | ||
| file_path: pendingExternalWritePath, |
There was a problem hiding this comment.
[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.
| 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"'); |
There was a problem hiding this comment.
[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.
| 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)
| it('keeps a marked workspace write on the existing WFS path', async () => { | ||
| const adapter = createBridgeFileSystemAdapter( | ||
| buildFactory({ trusted: true }), |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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.
| 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)
Local verification on a real stack — PR 8852I rebuilt this PR and its merge base into two independent bundles and ran both against a real Verdict: works as advertised, boundaries hold. LGTM from a verification standpoint. Three non-blocking notes for reviewers at the end. Setup
Bundle sanity: A/B: the authorization loop actually closesSame scripted model, same prompt, same approval action — only the bundle differs.
New files land as Screenshots — real Web Shell UI, identical user action1. Head — the approval request for a path outside the bound workspace 2. Head — after On-disk readback: 3. Base — same prompt, same approval: Security boundaries (all on head)
Repo tests
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 ( // 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 2. The external writer does not recover existing-file metadata from disk. 3. The opt-in asymmetry in 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,各自跑真实 结论:功能符合描述,边界稳固,从验证角度 LGTM。 文末有三条不阻塞的提示。 环境
Bundle 校验: A/B:授权链路确实闭合同一脚本化模型、同一 prompt、同一批准动作,只有 bundle 不同。
新文件以 截图 —— 真实 Web Shell UI,相同用户操作1. Head —— 针对 workspace 外路径的批准请求 2. Head —— 点击 磁盘回读: 3. Base —— 同样的 prompt、同样的批准: 安全边界(均在 head 上验证)
仓库测试
给 reviewer 的提示(不阻塞合并)1. provenance 标记对第三方 ACP 客户端是可见的。 我用最小 stdio 客户端拉起 // head
"_meta": { "bom": false, "qwen-code/tool-write-origin": { "version": 1, "source": "write_file" } }
// base
"_meta": { "bom": false }对通用客户端它是惰性的(对方仍由自己的 fs 策略决定),这与 PR 的定位一致。但 Zed 等编辑器集成此后会在每次内置写入中看到一个新的厂商 2. 外部 writer 不会从磁盘恢复已有文件的元数据。 3. PR 描述中已承认的遗留风险(批准到写入之间父目录的路径竞态)不在本次验证范围内,我没有尝试构造。 |



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
0600for 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 ACPwriteTextFilerequest was still rejected by the workspace filesystem withpath_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
write_fileto 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.path_outside_workspace; also verify untrusted, stale-generation, oversized, special-file, and external leaf-symlink writes remain denied.Evidence (Before & After)
Before: the deterministic real-daemon flow reported
write_fileas failed withpath escapes workspaceafterallow_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_onceand YOLO writes with exact file readback and no shell fallback;reject_onceissues 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
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 realqwen serve/ACP child.Risk & Scope
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 工具权限检查。最终委托的 ACPwriteTextFile请求仍会被 workspace filesystem 以path_outside_workspace拒绝,因此内置工具失败,模型还可能通过 shell 重试同一操作。本改动闭合该授权链路,同时保留委托写入和现有 daemon 文件系统安全边界。Reviewer 测试计划
如何验证
write_file写入外部绝对路径,并批准准确的内置工具请求。预期工具完成、目标内容精确一致,且没有 shell 调用。path_outside_workspace;同时验证 untrusted、过期 generation、超限、特殊文件和外部叶子 symlink 写入仍被拒绝。证据(改动前后)
改动前:确定性的真实 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、Node.js 24.12.0、应用仓库 Ink patch 的 npm workspace install、本地构建的
dist/cli.js,以及由 fake OpenAI server 驱动的真实qwen serve/ACP child。风险与范围
关联 Issue
关闭 #8851。
关联 #8618 和 #8620。