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

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
@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 PR — packages/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

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

@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.

@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 ❌ failed — path 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 f6d7456(agent/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 ✅ completed,0644 保留
覆盖已存在的 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-bundle:Startup 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 描述中已承认的遗留风险(批准到写入之间父目录的路径竞态)不在本次验证范围内,我没有尝试构造。

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

3 participants