diff --git a/docs/design/2026-09-16-runtime-file-sandbox.md b/docs/design/2026-09-16-runtime-file-sandbox.md new file mode 100644 index 00000000000..2caf5a0943a --- /dev/null +++ b/docs/design/2026-09-16-runtime-file-sandbox.md @@ -0,0 +1,49 @@ +# Runtime file tools in the execution sandbox + +[English](2026-09-16-runtime-file-sandbox.md) | [简体中文](2026-09-16-runtime-file-sandbox.zh-CN.md) + +## Status and problem + +Implemented internal integration stage, 2026-09-16, following [runtime Shell integration](2026-09-16-runtime-shell-sandbox.md). Before this stage the registry exposed only Shell and task_stop. The prototype file worker accepted small UTF-8 JSON writes, replaced symlinks, and did not preserve existing modes. Production Write/Edit additionally created directories on the host. Simply registering those tools would both bypass confinement and leave existing files uneditable without Read's prior-read cache. + +## Scope and policy + +Keep the existing trusted-host-only bare noninteractive admission and fixed workspace ceiling. Register the existing Read, Write, Edit, Shell and task_stop tools. No settings, environment switch, public CLI flag, Landlock implementation, or legacy whole-CLI removal is included. Other tools, hooks, MCP, LSP, extensions and worktree effects retain their previous restrictions. Custom filesystem delegation is rejected in this mode; ordinary runtime delegation is unchanged. + +The completed runtime-integration branch also prepares the next public CLI stage by admitting Monitor, Glob, LS and same-workspace in-process agents under the same immutable policy. Monitor uses the runtime shell executor; nested agents inherit the policy and cannot request worktrees, alternate working directories, teammates, custom executors, hooks or MCP servers. These additions do not create a public enablement path in this stage, and the unsupported variants fail before setup. The tracked verifier's registry assertions include this final internal runtime set. + +The current filesystem policy allows host reads and does not promise read confidentiality. Read keeps its existing content processing and inode-based prior-read cache. Reject PDF processing before any metadata probe, extraction or rasterization can start a host subprocess; users may invoke suitable utilities through confined Shell. Ordinary image processing is in memory and host model requests retain their trusted-runtime status. + +## Write execution + +Add one internal runtime write helper at the final Write/Edit write sites. Without policy it delegates to the existing FileSystemService, including tool-write provenance. With policy it uses the existing text encoder to preserve charset, BOM and line endings, then sends bytes to the installed worker through the existing confined pipe launch. Directory creation moves into the worker; the tool's host mkdir is skipped. Read-only policy rejects file mutations, even with YOLO. No failure retries on the host. + +Capture an expected regular-file version before reading/preparing the original content: device, inode, size, mtime and ctime, using bigint stat fields serialized as decimal strings; a missing file is null. Never refresh that version after reading. The worker checks the version before creating directories or temporary files and again through atomicWriteFile's assertCanCommit callback immediately before commit. A changed/replaced/disappeared/appeared target fails with a structured stale-file error that asks the caller to re-read. This narrows the added worker-launch race window; it is not atomic compare-and-swap and does not provide locking against arbitrary simultaneous writers. + +Replace the prototype JSON-content transport with a bounded JSON header followed by newline and raw encoded bytes. The header is at most 16 KiB and validates operation, absolute destination and version shape. Content is not subject to the prototype's 1 MiB JSON limit; the tool's existing in-memory text preparation remains. The worker reuses atomicWriteFile for mode preservation, fsync, symlink-following behavior and its established ownership/cross-device fallbacks. Existing in-workspace and dangling symlinks retain their meaning; paths resolving outside writable grants are denied by the kernel. Special files are rejected even when the prior-read cache is disabled. + +The worker returns structured success or filesystem error codes. The client accepts success only with a confirmed sandbox receipt, exit zero and a valid reply; uncertain completion is never replayed. Preserve ordinary tool errors, confirmation, replacement matching, diff display, artifact metadata and post-write cache updates. Existing attribution records only update host memory. File checkpointing is unavailable while this internal policy is active because its host-side backup and rewind lifecycle is outside the confined file path. + +## Affected code and compatibility + +Core Config changes only the restricted tool registry/allowlist and rejects custom filesystem services. Write/Edit capture versions and route the final mutation. The shared file-content pipeline rejects PDF host processing under policy. The worker/client transport and Linux prototype verifier change together; this API is internal and has no public compatibility promise. Encoding and atomic-write implementations are reused rather than duplicated. + +Historical runtime Shell and API-stage evidence remains labeled by its frozen artifact. This stage rebuilds both worker assets and reruns the corresponding Linux acceptance after the protocol change. Remaining file-producing tools, full frontend/effect inventory, read confidentiality, inode-wide hard-link immutability and atomic CAS remain outside this stage. + +## Validation and acceptance + +Use the repository feat-dev and e2e-testing workflows. Before implementation, drive the global CLI with a deterministic local model and disposable workspace/HOME/state to establish native Read/Edit semantics and unconstrained outside writes. Keep the ordinary installed CLI baseline distinct from the internal policy launcher. + +Unit coverage must check default delegation, policy routing without host mkdir/write, version drift and absent/appeared targets, regular-file enforcement, structured worker failures and custom-adapter rejection. Preserve the existing Write/Edit/Read suites. On real Linux drive production Read/Write/Edit turns, verify the restricted registry, Unicode and non-UTF-8 bytes, BOM/CRLF, existing modes, nested creation, allowed and denied symlink traversal, read-only denial, two runtime ceilings and cancellation without replay. Add deterministic worker-level stale-version tests and content larger than the old 1 MiB cap. PDF rejection must be proven before a helper process launches. Rerun existing Shell lifecycle and adapter tests. + +Acceptance requires build, typecheck, bundle, focused tests, matching final source/artifact hashes, two clean self-audits and independent review. Record exact results and limitations in .qwen/e2e-tests/runtime-file-sandbox.md. No unresolved design choices remain for this internal stage. + +## Verification results + +On 2026-09-16 the global qwen 0.23.4 baseline completed seven actual Read/Edit calls before implementation. At recorded revision `661f5416f2b0e9c73a47582fac742ef78d777a3d`, the candidate passed 31 production headless runtime groups and 34 adapter/worker groups on Lima qwen-sbx, ARM64 Linux 7.0.0-31-generic, Node 22.22.1 and bubblewrap 0.11.1. These frozen measurements describe that revision and its recorded artifact; later review fixes require their own exact-head validation. The measurements include the existing Shell lifecycle, private PID namespace cleanup, file semantics, outside-write denial and failed setup without host replay. Raw binary payloads larger than 1 MiB, empty content, mode/symlink preservation, stale/replaced/removed/appeared targets and FIFO rejection passed in the worker harness. + +Build, typecheck and bundle passed. Focused core and CLI tests passed 3,328 cases with one pre-existing skip. The runtime artifact has 4,791 matching source inputs; launcher SHA-256 is `18a7a557bf41e8ecc3baa3de1a6826fccc21a7fb93cbac3f49584d83a362b8e1`. The adapter artifact has 278 matching inputs; its worker SHA-256 `439e880137e0ef7ab383fb54f8d4f42804a406e363c6513468a102c7f90e3a18` matches the production bundle worker. Exact evidence and cleanup records are in `.qwen/e2e-tests/runtime-file-sandbox.md`. + +The Linux evidence covers this ARM64 environment. Linux x64, other kernels, public frontend enablement and Landlock remain unvalidated or unimplemented. Cancellation and uncertain receipt handling use the existing real-Linux adapter lifecycle checks plus file-client unit rejection tests; the runtime file groups do not interrupt an in-progress file commit. No atomic compare-and-swap or general rollback guarantee is claimed. + +For the recorded revision, package dry-run included both installed workers and the recorded source and worker hashes were rechecked afterward. These historical hashes are not evidence for later commits. diff --git a/docs/design/2026-09-16-runtime-file-sandbox.zh-CN.md b/docs/design/2026-09-16-runtime-file-sandbox.zh-CN.md new file mode 100644 index 00000000000..4d4bcff1d89 --- /dev/null +++ b/docs/design/2026-09-16-runtime-file-sandbox.zh-CN.md @@ -0,0 +1,49 @@ +# Runtime 文件工具接入执行沙箱 + +[English](2026-09-16-runtime-file-sandbox.md) | [简体中文](2026-09-16-runtime-file-sandbox.zh-CN.md) + +## 状态与问题 + +2026-09-16,继 [Runtime Shell 接入](2026-09-16-runtime-shell-sandbox.zh-CN.md)之后已实现的内部集成阶段。此前注册表仅开放 Shell 和 task_stop。此前原型文件 worker 只接受小型 UTF-8 JSON 写入,会替换符号链接,也不保留已有文件权限。此前生产 Write/Edit 还会在宿主创建目录。直接注册这些工具既会绕过约束,又会因为缺少 Read 的先读缓存而无法编辑已有文件。 + +## 范围与策略 + +保留现有仅可信宿主可注入的 bare 非交互准入和固定工作区权限上限。注册已有 Read、Write、Edit、Shell 和 task_stop 工具。本阶段不增加设置、环境开关或公开 CLI 参数,不实现 Landlock,也不移除旧的整个 CLI 沙箱。其他工具、hook、MCP、LSP、扩展和 worktree 副作用继续受上一阶段限制。此模式拒绝自定义文件系统委托,普通 runtime 的委托行为保持不变。 + +已完成的 runtime 接入分支还为下一阶段公开 CLI 接入做准备,在同一不可变策略下准入 Monitor、Glob、LS 和同工作区进程内 agent。Monitor 使用 runtime shell executor;嵌套 agent 继承策略,并且不能请求 worktree、其他工作目录、teammate、自定义 executor、hook 或 MCP server。这些能力在本阶段不提供公开启用入口,不支持的变体会在设置前失败。受跟踪 verifier 的 registry 断言包含这组最终内部 runtime 工具。 + +当前文件系统策略允许宿主读取,不承诺读取机密性。Read 保留原内容处理流程与基于 inode 的先读缓存。在 PDF 元数据探测、文本提取或栅格化启动任何宿主子进程之前拒绝 PDF 处理;用户可通过受约束的 Shell 调用相应工具。普通图片处理在内存中进行,宿主模型请求继续属于可信 runtime 行为。 + +## 写入执行 + +在 Write/Edit 最终写入处增加一个内部 runtime 写入 helper。无策略时继续委托已有 FileSystemService,并保留工具写入来源。有策略时复用现有文本编码器保留字符集、BOM 和换行,再通过已有受约束的管道启动把字节发给安装的 worker。目录创建移入 worker,跳过工具层的宿主 mkdir。只读策略拒绝文件变更,YOLO 也不例外。任何失败都不在宿主重试。 + +在读取或准备原文之前捕获预期普通文件版本:device、inode、size、mtime 和 ctime,使用 bigint stat 字段并序列化为十进制字符串;不存在的文件为 null。读取后绝不重新获取版本来替代它。worker 在创建目录或临时文件之前检查版本,并通过 atomicWriteFile 的 assertCanCommit 回调在提交前再次检查。目标变化、替换、消失或新出现时返回结构化的文件过期错误,要求重新读取。这缩小了新增 worker 启动带来的竞争窗口,但不是原子 compare-and-swap,也不为任意并发写入方提供锁。 + +将原型的 JSON 内容传输改为有限长度 JSON header、换行和原始编码字节。header 最多 16 KiB,校验操作、绝对目标路径和版本结构。内容不再受原型 1 MiB JSON 限制;工具仍使用现有内存文本准备流程。worker 复用 atomicWriteFile,保留 mode、fsync、跟随符号链接的行为及已有所有权/跨设备回退。工作区内链接和悬空链接保留原有语义;解析到可写授权之外的路径由内核拒绝。即使禁用先读缓存,也拒绝特殊文件。 + +worker 返回结构化成功或文件系统错误码。client 只有在沙箱回执已确认、退出码为零且回复有效时才接受成功;无法确认的完成绝不重放。保留普通工具错误、确认、替换匹配、diff 展示、artifact 元数据和写后缓存更新。已有署名记录仅更新宿主内存。此内部策略启用时不提供文件 checkpoint,因为其宿主侧备份与回滚生命周期不在受约束的文件路径内。 + +## 影响代码与兼容性 + +Core Config 仅调整受限工具注册表/允许列表,并拒绝自定义文件系统服务。Write/Edit 捕获版本并路由最终变更。共享文件内容流程在策略下拒绝宿主 PDF 处理。worker/client 协议与 Linux 原型验证器同步修改;此 API 属于内部实现,没有公开兼容性承诺。复用编码与原子写实现,不重新复制。 + +历史 Runtime Shell 和 API 阶段证据继续以冻结 artifact 标识。本阶段重新构建两个 worker 资源,并在协议变化后重跑相应 Linux 验收。其他产出文件的工具、完整前端/副作用清单、读取机密性、hard link 的 inode 级不可变性和原子 CAS 不属于本阶段。 + +## 验证与验收 + +遵循仓库 feat-dev 和 e2e-testing 流程。实现前,用确定性的本地模型和临时 workspace/HOME/state 驱动全局 CLI,确认原有 Read/Edit 语义与未受约束的根外写入。区分普通安装 CLI 基线与内部策略 launcher。 + +单测覆盖默认委托、无宿主 mkdir/write 的策略路由、版本变化与目标新出现/消失、普通文件限制、worker 结构化失败和自定义 adapter 拒绝。保留已有 Write/Edit/Read 测试。在真实 Linux 驱动生产 Read/Write/Edit 回合,验证受限注册表、Unicode 与非 UTF-8 字节、BOM/CRLF、已有 mode、嵌套创建、允许和拒绝的链接遍历、只读拒绝、两个 runtime 上限,以及取消不重放。增加确定性的 worker 版本冲突测试和超过旧 1 MiB 上限的内容测试。证明 PDF 在辅助进程启动前被拒绝。重跑既有 Shell 生命周期与 adapter 测试。 + +验收要求 build、typecheck、bundle、定向测试、最终源码/artifact hash 一致、两轮干净自审及独立审查。精确结果与限制记录在 .qwen/e2e-tests/runtime-file-sandbox.md。本内部阶段没有未解决的设计选择。 + +## 验证结果 + +2026-09-16,实施前的全局 qwen 0.23.4 基线完成了七次真实 Read/Edit 调用。在记录的 revision `661f5416f2b0e9c73a47582fac742ef78d777a3d` 上,候选产物在 Lima qwen-sbx、ARM64 Linux 7.0.0-31-generic、Node 22.22.1、bubblewrap 0.11.1 上通过 31 组生产 headless runtime 检查和 34 组 adapter/worker 检查。这些冻结的测量只描述该 revision 及其记录的 artifact;后续审查修复需要在准确 head 上重新验证。测量覆盖原有 Shell 生命周期、私有 PID namespace 清理、文件语义、越界写拒绝和启动失败不在宿主重放。worker 套件通过了超过 1 MiB 的原始二进制内容、空内容、权限/符号链接保留、目标变化/替换/删除/新出现冲突和 FIFO 拒绝。 + +Build、typecheck 和 bundle 通过。定向 core 与 CLI 单测通过 3,328 项,另有一项原有跳过。runtime 产物的 4,791 个源码输入匹配,launcher SHA-256 为 `18a7a557bf41e8ecc3baa3de1a6826fccc21a7fb93cbac3f49584d83a362b8e1`。adapter 产物的 278 个输入匹配,其 worker SHA-256 `439e880137e0ef7ab383fb54f8d4f42804a406e363c6513468a102c7f90e3a18` 与生产 bundle worker 一致。精确证据与清理记录在 `.qwen/e2e-tests/runtime-file-sandbox.md`。 + +Linux 证据覆盖上述 ARM64 环境。Linux x64、其他内核、公开前端启用和 Landlock 尚未验证或实现。取消与不确定回执处理由现有真实 Linux adapter 生命周期检查和文件 client 单测拒绝路径共同覆盖;runtime 文件用例没有中断正在执行的文件提交。不承诺原子 compare-and-swap 或通用回滚。 + +对于记录的 revision,打包预检包含两个安装 worker,并在预检后再次确认了所记录源码与 worker hash。这些历史 hash 不作为后续提交的证据。 diff --git a/docs/design/2026-09-16-runtime-shell-sandbox.md b/docs/design/2026-09-16-runtime-shell-sandbox.md new file mode 100644 index 00000000000..c59be6c9d0b --- /dev/null +++ b/docs/design/2026-09-16-runtime-shell-sandbox.md @@ -0,0 +1,45 @@ +# Runtime shell sandbox integration + +[English](2026-09-16-runtime-shell-sandbox.md) | [简体中文](2026-09-16-runtime-shell-sandbox.zh-CN.md) + +## Status and problem + +Implementation stage, 2026-09-16. The structured process API and bwrap adapter are implemented and have Linux kernel evidence. Previously, production ShellTool launched through the legacy service without receiving an operator policy. This stage connects the trusted runtime to foreground and background shell execution. It builds on the [bwrap execution foundation](2026-09-17-bwrap-execution-foundation.md) and is not the public sandbox release. + +This document records the completed Shell-stage snapshot. The subsequent [runtime file-tool integration](2026-09-16-runtime-file-sandbox.md) expands the restricted registry to Read/Write/Edit and replaces the prototype worker protocol. Registry and artifact statements below describe this historical stage. + +## Design + +The existing runtime-only `loadCliConfig` host-policy argument accepts `shellExecutionSandbox`, which is copied into Config. There is no settings, environment, or public CLI switch. Config admits and freezes the original canonical workspace policy and protects both the runtime state and global configuration directories. Derived Configs inherit that ceiling; derivation or relocation outside it fails before mutation. The trusted host must construct a newly admitted Config to authorize another workspace. + +This internal mode requires a bare, noninteractive runtime and rejects explicit extensions, MCP servers, tool discovery, LSP, legacy whole-CLI sandboxing, and provisional workspaces. Its registry contains only `run_shell_command` and `task_stop`. Bare startup suppresses hooks and ambient extensions/MCP. Unsupported model tool calls cannot reach file tools, subagents, worktrees, or Exec. This is a constrained headless integration; arbitrary embedders, slash commands, TUI, ACP, and other effect entrypoints remain release gates. + +A runtime shell helper preserves the legacy path when no policy exists. With a policy, it builds an explicit `/bin/bash -c` launch, the existing sanitized shell environment and current session context, and invokes bwrap. The payload environment is handed to the relay through a mode-0600 control file and becomes bwrap's child environment instead of appearing in process arguments; the relay removes the file before spawning bwrap. Foreground, background, and promotion retain the existing registry, stream, cancellation, timeout, and host relay PID behavior. A failed admission or launch never falls back to an unconfined command. + +Sandbox receipt validation applies to ordinary completion and promoted terminal callbacks. Confirmed nonzero exits remain command failures. Unconfirmed termination produces an error warning that the command may have run and must not be automatically replayed. Interrupted termination cannot become success. Promotion is only a running snapshot; terminal validation and resource ownership continue after it. + +The sed preview/write optimization is disabled so the original command executes inside the sandbox. Git notes, PR binding metadata subprocesses, textual commit/PR attribution rewrites, and the system prompt's Git status snapshot are suspended in this internal mode; even `git status` can execute repository-configured clean filters on the host. Git status remains available through the confined Shell tool. Permission defaults conservatively ask instead of running host Git configuration probes, and confirmation does not perform those probes. Ordinary approval and YOLO never expand the OS policy. Background launch exceptions close their output stream. The review-worktree lease directory is hidden behind a private tmpfs after the workspace bind, so a confined process can neither read real leases nor forge records that later authorize host cleanup. The launcher creates an absent mount point only for a writable workspace; a read-only workspace skips an absent path because there is no lease to hide and the sandbox cannot create one. + +Workspace-write intentionally allows repository metadata changes, including `.git` hooks and configuration. A later host process must therefore treat the entire writable workspace as sandbox output and must not execute repository-controlled hooks, filters, or configuration without a separate trust decision. + +## Affected layers and boundaries + +Core Config owns immutable admission and restricted registration. CLI config transports trusted policy. ShellTool delegates launches and disables uncovered shortcuts. The bwrap adapter transports validated terminal status. Tests cover policy ownership, shell routing, default behavior, and Linux production headless execution. + +File-tool migration, complete effect inventory, attribution migration, retiring whole-CLI bwrap, Landlock, and automatic backend selection are later stages. Read confidentiality and inode-level immutability are not claimed. Host model/auth/session persistence remain outside the tool sandbox. No new public enabled mode is delivered here. + +## Validation and acceptance + +First run a global CLI baseline with an isolated HOME, disposable workspace/state, and a deterministic fake OpenAI server. It should demonstrate the existing unconstrained ShellTool behavior. Then build, typecheck, bundle, and run focused package tests, preserving the legacy shell suite. + +On real Linux, a test-only launcher invokes production `loadCliConfig`, auth initialization, and `runNonInteractive`, injecting only the trusted policy. Verify model HTTP and host session persistence, workspace writes, outside-write and closed-network denial, real sed confinement, unsupported file-tool rejection, foreground/background lifecycle, and two independent workspace policies. Explicitly label this production headless pipeline rather than claiming that the ordinary public `node dist/cli.js` accepts the mode. Smoke-test the ordinary bundle separately. Reuse the process adapter's Linux lifecycle evidence and rerun it when changed. + +Acceptance requires no unconfined fallback, no false terminal success, no implicit grants during derivation/relocation, and preserved default behavior. Record exact artifact and test evidence under `.qwen/e2e-tests/`. Complete two clean self-audit passes and independent code review. No open design questions remain for this bounded stage. + +## Verification results (2026-09-16) + +The implementation passed 2,769 focused tests (2,154 core and 615 CLI, with one existing skipped test), the full repository build, typecheck, bundle, targeted lint and formatting. The ordinary bundled CLI still reports `0.23.4`. The isolated global CLI baseline demonstrated the previous outside-write behavior before implementation. + +On Lima `qwen-sbx` (Linux `7.0.0-31-generic`, ARM64, Node `v22.22.1`, bwrap `0.11.1`), the tracked `scripts/sandbox-runtime` harness passed 22 checks through the production headless pipeline and real ShellTool lifecycle. The existing adapter harness passed 31 checks; its source input hashes remain unchanged, so that evidence is still current. Evidence includes two Config instances in one process with distinct session IDs and denied cross-root writes, host model requests and session JSONL, kernel write/network denial, restricted startup/tool registration, Git clean filters executing only inside the sandbox, and direct/background/promoted receipt-loss failures. Namespace cleanup checks compare identities rather than numeric PIDs. Review-lease lifecycle unit tests exercise both ordinary turn finalization and the registered exit callback, proving that only the ordinary mode invokes host cleanup; no additional destructive lease scenario was constructed. + +The final runtime v4 manifest has 4,787 source and test-script input hashes, all rechecked against the checkout after execution. Its launcher SHA-256 is `3be748159cd10d7b1f1054042061b3e6f6678aab707fabe995cb8e49267dd792`. The detailed baseline, intermediate LSP rejection-contract correction, Git snapshot and review-lease cleanup fixes, final reports and test limitations are in `.qwen/e2e-tests/runtime-shell-sandbox.md`. Linux x64, other kernels, macOS/Windows enforcement and a public CLI enablement path were not validated in this stage. diff --git a/docs/design/2026-09-16-runtime-shell-sandbox.zh-CN.md b/docs/design/2026-09-16-runtime-shell-sandbox.zh-CN.md new file mode 100644 index 00000000000..82e7319457f --- /dev/null +++ b/docs/design/2026-09-16-runtime-shell-sandbox.zh-CN.md @@ -0,0 +1,45 @@ +# Runtime Shell 沙箱接入 + +[English](2026-09-16-runtime-shell-sandbox.md) | [简体中文](2026-09-16-runtime-shell-sandbox.zh-CN.md) + +## 状态与问题 + +实现阶段,2026-09-16。结构化进程 API 与 bwrap adapter 已实现并获得 Linux 内核验证。此前生产 ShellTool 通过旧服务启动,未接收操作方策略。本阶段把可信 runtime 接入前台和后台 Shell 执行。它基于 [bwrap 执行底座](2026-09-17-bwrap-execution-foundation.zh-CN.md),不是面向用户的沙箱发布。 + +本文记录已完成的 Shell 阶段快照。后续 [runtime 文件工具接入](2026-09-16-runtime-file-sandbox.zh-CN.md)将受限 registry 扩展到 Read/Write/Edit,并替换原型 worker 协议。下文的 registry 和产物说明描述该历史阶段。 + +## 设计 + +现有 `loadCliConfig` 的 runtime-only host-policy 参数接收 `shellExecutionSandbox` 并传给 Config,不增加设置、环境变量或公开 CLI 开关。Config 准入并冻结原始规范化 workspace 策略,同时保护 runtime 状态和全局配置目录。派生 Config 继承该权限上限;超出上限的派生或目录切换在修改状态前失败。要授权另一个 workspace,可信宿主必须重新构造并准入 Config。 + +内部模式要求 bare、非交互 runtime,并拒绝显式扩展、MCP server、工具发现、LSP、旧的整个 CLI 沙箱和 provisional workspace。工具注册表仅包含 `run_shell_command` 与 `task_stop`。Bare 启动关闭 hook 和环境中的扩展/MCP。不支持的模型工具调用无法进入文件工具、子代理、worktree 或 Exec。这是受限的 headless 集成;任意嵌入方式、slash command、TUI、ACP 和其他副作用入口仍是发布前置条件。 + +Runtime shell helper 在没有策略时保留旧路径。有策略时构造显式 `/bin/bash -c` 启动描述,使用已有清洗后的 shell 环境和当前 session 上下文,再调用 bwrap。Payload 环境通过权限为 0600 的控制文件交给 relay,并作为 bwrap 子进程环境传递,不再出现在进程参数中;relay 在启动 bwrap 前删除该文件。前台、后台和转后台保留现有 registry、输出流、取消、超时及宿主 relay PID 行为。准入或启动失败绝不退回无约束命令。 + +沙箱回执校验同时覆盖普通完成和转后台后的终结回调。已确认的非零退出仍是命令失败。无法确认的终结产生错误,说明命令可能已经执行,不得自动重放。中断不能变成成功。转后台只是运行中快照;此后仍保留终结校验和资源所有权。 + +禁用 sed 预览/写入优化,让原始命令在沙箱内执行。内部模式暂停 Git notes、PR 绑定的元数据子进程、纯文本 commit/PR 署名改写和系统提示词的 Git 状态快照,因为即使 `git status` 也能在宿主执行仓库配置的 clean filter。仍可通过受约束的 Shell 工具查看 Git 状态。权限默认保守询问,不执行宿主 Git 配置探测,确认阶段也不进行这些探测。普通批准和 YOLO 都不会扩大 OS 策略。后台启动异常会关闭输出流。在工作区 bind 之后,review worktree lease 目录会被私有 tmpfs 遮蔽,因此受约束进程既不能读取真实 lease,也不能伪造随后可授权宿主清理的记录。仅在 workspace 可写时由启动方创建缺失的挂载点;只读 workspace 遇到缺失路径时直接跳过,因为此时既没有 lease 需要遮蔽,沙箱也无法创建 lease。 + +Workspace-write 会有意允许修改仓库元数据,包括 `.git` hook 和配置。后续宿主进程因此必须把整个可写工作区视为沙箱输出;没有单独的信任决策时,不得执行由仓库控制的 hook、filter 或配置。 + +## 影响层次与边界 + +Core Config 负责不可变准入和受限工具注册。CLI config 传递可信策略。ShellTool 委托启动并关闭尚未覆盖的捷径。bwrap adapter 传递经过校验的终结状态。测试覆盖策略归属、Shell 路由、默认行为和 Linux 生产 headless 执行。 + +文件工具迁移、完整副作用清单、署名流程迁移、移除整个 CLI 的 bwrap、Landlock 和自动后端选择留到后续阶段。不声明读取机密性或 inode 级不可变性。宿主模型、认证和 session 持久化位于工具沙箱之外。本阶段不交付新的公开启用模式。 + +## 验证与验收 + +先使用隔离 HOME、临时 workspace/state 和确定性的假 OpenAI server 执行全局 CLI 基线,证明当前 ShellTool 的无约束行为。随后 build、typecheck、bundle 并执行针对性 package 测试,保留旧 Shell 测试集。 + +在真实 Linux 上,测试专用 launcher 调用生产 `loadCliConfig`、认证初始化和 `runNonInteractive`,仅注入可信策略。验证模型 HTTP 与宿主 session 持久化、workspace 写入、根外写入和关闭网络的拒绝、真实 sed 约束、不支持文件工具的拒绝、前后台生命周期,以及两个独立 workspace 的策略。准确称为生产 headless pipeline,不宣称公开 `node dist/cli.js` 可以启用该模式。单独 smoke test 普通 bundle。复用进程 adapter 的 Linux 生命周期证据;改动 adapter 时重跑。 + +验收要求:无宿主回退、无虚假终结成功、派生/切换目录不隐式授权、默认行为不退化。精确 artifact 与测试证据记录在 `.qwen/e2e-tests/`。完成两轮干净自审和独立代码审查。本阶段没有尚未确定的设计问题。 + +## 验证结果(2026-09-16) + +实现通过 2,769 项定向测试(core 2,154 项、CLI 615 项,另有 1 项原有跳过测试)、完整仓库 build、typecheck、bundle、定向 lint 和格式检查。普通 bundle CLI 仍返回 `0.23.4`。实现前的隔离全局 CLI 基线证明了此前可越界写入的行为。 + +在 Lima `qwen-sbx`(Linux `7.0.0-31-generic`、ARM64、Node `v22.22.1`、bwrap `0.11.1`)上,仓库中的 `scripts/sandbox-runtime` 套件通过生产 headless 链路与真实 ShellTool 生命周期完成 22 项检查,已有 adapter 套件通过 31 项,源码输入 hash 未变化,因此该结果继续有效。证据覆盖同一进程内两个 Config 的不同 session ID 与交叉写入拒绝、宿主模型请求和 session JSONL、内核写入/网络拒绝、受限启动/工具注册、Git clean filter 仅在沙箱内执行,以及前台、后台和转后台时的回执丢失失败。namespace 清理检查比较身份,不依赖数值 PID。review lease 清理的生命周期单测同时覆盖正常回合收尾与退出回调,确认仅普通模式会调用宿主清理;未额外构造破坏性 lease 场景。 + +最终 runtime v4 manifest 包含 4,787 个源码与测试脚本输入 hash,执行后全部与工作区重新比对一致。launcher SHA-256 为 `3be748159cd10d7b1f1054042061b3e6f6678aab707fabe995cb8e49267dd792`。详细基线、中间 LSP 拒绝契约修正、Git 快照与 review lease 清理修复、最终报告和测试边界位于 `.qwen/e2e-tests/runtime-shell-sandbox.md`。本阶段未验证 Linux x64、其他内核、macOS/Windows 内核约束或公开 CLI 启用入口。 diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 1de501611a7..d7be1f130f1 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import { GOAL_DEFAULT_TOKEN_BUDGET, @@ -4816,6 +4817,100 @@ describe('loadCliConfig with includeDirectories', () => { expect(config.isLspEnabled()).toBe(false); }); + it('transports the trusted host policy and ignores an undeclared top-level setting', async () => { + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as import('node:fs').Stats); + vi.mocked(fs.realpathSync).mockImplementation((value) => value.toString()); + process.argv = ['node', 'script.js', '--bare', '-p', 'fixture']; + const argv = await parseArguments(); + const policy = { + workspace: path.resolve(path.sep, 'sandbox-fixture', 'workspace'), + installation: path.resolve('/trusted-install'), + state: path.resolve('/trusted-state'), + maskedPaths: [ + path.resolve(path.sep, 'sandbox-fixture', 'workspace', '.env'), + ], + filesystem: 'workspace-write' as const, + network: 'closed' as const, + }; + const config = await loadCliConfig( + {}, + argv, + policy.workspace, + [], + undefined, + undefined, + undefined, + undefined, + false, + { shellExecutionSandbox: policy }, + ); + expect(config.getShellExecutionSandbox()).toMatchObject({ + ...policy, + maskedPaths: expect.any(Array), + }); + expect(config.getShellExecutionSandbox()?.maskedPaths).toEqual([ + policy.maskedPaths[0], + path.join(policy.workspace, '.qwen', 'review-leases'), + ]); + expect(config.getCoreTools()).toEqual( + expect.arrayContaining([ + ToolNames.SHELL, + ToolNames.TASK_STOP, + ToolNames.READ_FILE, + ToolNames.WRITE_FILE, + ToolNames.EDIT, + ]), + ); + const ordinary = await loadCliConfig( + { shellExecutionSandbox: policy } as Settings, + argv, + policy.workspace, + [], + ); + expect(ordinary.getShellExecutionSandbox()).toBeUndefined(); + const rejectedModes: Array<[string, Partial]> = [ + ['interactive frontend', { bare: false }], + ['missing prompt', { prompt: undefined }], + ['prompt-interactive frontend', { promptInteractive: 'fixture' }], + ['stream-json frontend', { inputFormat: 'stream-json' }], + ['worktree startup', { worktree: 'review' }], + ['additional context directories', { includeDirectories: ['/outside'] }], + ]; + for (const [, overrides] of rejectedModes) { + await expect( + loadCliConfig( + {}, + { ...argv, ...overrides }, + policy.workspace, + [], + undefined, + undefined, + undefined, + undefined, + false, + { shellExecutionSandbox: policy }, + ), + ).rejects.toThrow('requires bare noninteractive mode'); + } + vi.stubEnv('QWEN_AGENT_EXECUTION_BACKEND', 'docker'); + await expect( + loadCliConfig( + {}, + argv, + policy.workspace, + [], + undefined, + undefined, + undefined, + undefined, + false, + { shellExecutionSandbox: policy }, + ), + ).rejects.toThrow('agent execution environments'); + }); + it('should ignore coreTools overrides in bare mode', async () => { process.argv = ['node', 'script.js', '--bare', '--core-tools', 'web_fetch']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0b21bf54c3d..13251707193 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1622,6 +1622,7 @@ export async function loadCliConfig( */ hostPolicy?: { toolInvocationGuard?: ToolInvocationGuard; + shellExecutionSandbox?: ConfigParameters['shellExecutionSandbox']; /** Host-managed session whose exact private cwd is bound after bootstrap. */ provisionalWorkspace?: true; sessionRestore?: { @@ -1639,6 +1640,42 @@ export async function loadCliConfig( process.env['QWEN_DEBUG_LOG_FILE'] = '1'; } const bareMode = isBareMode(argv.bare); + const requestedShellExecutionSandbox = hostPolicy?.shellExecutionSandbox; + const shellExecutionSandbox = requestedShellExecutionSandbox + ? { + ...requestedShellExecutionSandbox, + maskedPaths: [ + ...(requestedShellExecutionSandbox.maskedPaths ?? []), + path.join( + requestedShellExecutionSandbox.workspace, + '.qwen', + 'review-leases', + ), + ], + } + : undefined; + const sandboxEnabled = Boolean(shellExecutionSandbox); + if ( + sandboxEnabled && + (!bareMode || + !argv.prompt || + argv.promptInteractive !== undefined || + argv.inputFormat === 'stream-json' || + argv.acp || + argv.experimentalAcp || + argv.worktree !== undefined || + argv.experimentalLsp || + argv.mcpConfig || + argv.extensions?.length || + argv.includeDirectories?.length || + overrideExtensions?.length || + Object.keys(sessionMcpServers ?? {}).length || + provisionalWorkspace) + ) { + throw new Error( + 'The internal tool execution sandbox requires bare noninteractive mode without ACP, worktrees, LSP, MCP, extensions or provisional workspaces.', + ); + } const safeMode = argv.safeMode !== undefined ? argv.safeMode : isSafeModeEnv(); @@ -1677,8 +1714,7 @@ export async function loadCliConfig( if (!Storage.hasRuntimeBaseDirContext()) { Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd); } - - const ideMode = settings.ide?.enabled ?? false; + const ideMode = !sandboxEnabled && (settings.ide?.enabled ?? false); const folderTrust = settings.security?.folderTrust?.enabled ?? false; const trustedFolder = isWorkspaceTrusted(settings).isTrusted === true; @@ -2117,6 +2153,11 @@ export async function loadCliConfig( bareMode || safeMode ? ({} as Settings) : settings, argv, ); + if (shellExecutionSandbox && sandboxConfig) { + throw new Error( + 'Tool execution sandbox cannot be combined with a whole-CLI sandbox.', + ); + } const screenReader = argv.screenReader !== undefined ? argv.screenReader @@ -2372,6 +2413,7 @@ export async function loadCliConfig( bareMode || safeMode ? undefined : settings.permissions?.autoMode, }, toolInvocationGuard: hostPolicy?.toolInvocationGuard, + shellExecutionSandbox, // Permission rule persistence callback (writes to settings files). onPersistPermissionRule: async (scope, ruleType, rule) => { const currentSettings = loadSettings(cwd); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 9764858dbf7..9a9774846fe 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -70,7 +70,11 @@ import type { ControlService } from './nonInteractive/control/ControlService.js' import { CommandKind, type ExecutionMode } from './ui/commands/types.js'; import { goalCommand } from './ui/commands/goalCommand.js'; import { filterCommandsForMode } from './services/commandUtils.js'; -import { _resetCleanupFunctionsForTest } from './utils/cleanup.js'; +import { + _resetCleanupFunctionsForTest, + runExitCleanup, +} from './utils/cleanup.js'; +import { cleanupReviewWorktreeLeases } from './services/review-worktree-lease.js'; import { AlreadyReportedError, _resetExitLatchForTest, @@ -84,6 +88,9 @@ const getActiveInteractionSpanSpy = vi.hoisted(() => vi.fn()); const addAgentOutputMessageAttributesSpy = vi.hoisted(() => vi.fn()); const interactionSpan = vi.hoisted(() => ({})); vi.mock('./ui/hooks/atCommandProcessor.js'); +vi.mock('./services/review-worktree-lease.js', () => ({ + cleanupReviewWorktreeLeases: vi.fn(), +})); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); @@ -498,6 +505,48 @@ describe('runNonInteractive', () => { } } + it.each([false, true])( + 'scopes review lease cleanup to the ordinary runtime (sandbox=%s)', + async (sandbox) => { + setupMetricsMock(); + mockConfig.getShellExecutionSandbox = vi.fn().mockReturnValue( + sandbox + ? { + workspace: '/test/project', + installation: '/test/installation', + state: '/test/state', + filesystem: 'workspace-write', + network: 'closed', + } + : undefined, + ); + vi.mocked(cleanupReviewWorktreeLeases).mockClear(); + mockLlmClient.sendMessageStream.mockImplementation(async function* () { + await runExitCleanup(); + yield { type: LlmEventType.Content, value: 'Done' }; + }); + + await runNonInteractive(mockConfig, mockSettings, 'Hello', 'test-prompt'); + + if (sandbox) { + expect(cleanupReviewWorktreeLeases).not.toHaveBeenCalled(); + } else { + expect(cleanupReviewWorktreeLeases).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + promptId: 'test-prompt', + repositoryRoot: '/test/project', + gitTimeout: 1_000, + }); + expect(cleanupReviewWorktreeLeases).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + promptId: 'test-prompt', + repositoryRoot: '/test/project', + gitTimeout: undefined, + }); + } + }, + ); + type GoalControlCase = { name: string; input: string; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 102870f4a14..04b9a3b2db6 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -593,13 +593,16 @@ export async function runNonInteractive( // Get readonly values once at the start const sessionId = config.getSessionId(); const permissionMode = config.getApprovalMode() as PermissionMode; - const cleanupReviewWorktrees = (gitTimeout?: number) => + const cleanupReviewWorktrees = (gitTimeout?: number) => { + // Review leases live in the tool-writable workspace in this mode. + if (config.getShellExecutionSandbox?.()) return; cleanupReviewWorktreeLeases({ sessionId, promptId: prompt_id, repositoryRoot: config.getProjectRoot(), gitTimeout, }); + }; const unregisterReviewWorktreeCleanup = registerCleanup(() => cleanupReviewWorktrees(1_000), ); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index b3c3418b77f..7762baab4fb 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -133,6 +133,7 @@ import { type Extension, } from '../extension/extensionManager.js'; import { SkillManager } from '../skills/skill-manager.js'; +import * as sandboxPolicy from '../sandbox/runtime-shell-policy.js'; import type { SkillConfig } from '../skills/types.js'; import { createSkillScopedAgentConfig } from '../memory/skillReviewAgentPlanner.js'; import { maybeRunAutoSkillCurator } from '../skills/skill-curator.js'; @@ -2982,7 +2983,209 @@ describe('Server Config (config.ts)', () => { } }); + describe('tool sandbox initialization', () => { + const parameters = () => ({ + ...baseParams, + sandbox: undefined, + cwd: TARGET_DIR, + interactive: true, + bareMode: false, + enableAutoSkill: true, + shellExecutionSandbox: { + workspace: path.resolve(TARGET_DIR), + installation: path.resolve('/installation'), + state: path.resolve('/sandbox-state'), + filesystem: 'workspace-write' as const, + network: 'closed' as const, + }, + }); + + it('stops before recording, hooks, skills and registry setup when probing fails', async () => { + const probe = vi + .spyOn(sandboxPolicy, 'probeShellSandbox') + .mockRejectedValue(new Error('probe unavailable')); + try { + const config = new Config(parameters()); + const record = vi.spyOn( + config as unknown as { activateChatRecording: () => unknown }, + 'activateChatRecording', + ); + await expect(config.initialize()).rejects.toThrow('probe unavailable'); + expect(record).not.toHaveBeenCalled(); + expect(HookSystem).not.toHaveBeenCalled(); + expect(SkillManager.prototype.startWatching).not.toHaveBeenCalled(); + expect(ToolRegistry.prototype.registerFactory).not.toHaveBeenCalled(); + } finally { + probe.mockRestore(); + } + }); + + it('keeps pure skill reads and registers only admitted tools after a successful probe', async () => { + const probe = vi + .spyOn(sandboxPolicy, 'probeShellSandbox') + .mockResolvedValue(); + try { + const config = new Config(parameters()); + const refreshExtensions = vi.spyOn( + config.getExtensionManager(), + 'refreshCache', + ); + await config.initialize(); + expect(probe).toHaveBeenCalledWith( + config.getShellExecutionSandbox(), + undefined, + ); + expect(HookSystem).not.toHaveBeenCalled(); + expect(maybeRunAutoSkillCurator).not.toHaveBeenCalled(); + expect(refreshExtensions).not.toHaveBeenCalled(); + expect(SkillManager.prototype.startWatching).not.toHaveBeenCalled(); + expect(SkillManager.prototype.refreshCache).toHaveBeenCalled(); + expect( + (ToolRegistry.prototype.registerFactory as Mock).mock.calls.map( + (call) => call[0], + ), + ).toEqual([ + ToolNames.SHELL, + ToolNames.TASK_STOP, + ToolNames.READ_FILE, + ToolNames.WRITE_FILE, + ToolNames.EDIT, + ToolNames.MONITOR, + ToolNames.AGENT, + ToolNames.GLOB, + ToolNames.LS, + ToolNames.ASK_USER_QUESTION, + ]); + expect(ToolRegistry.prototype.discoverAllTools).not.toHaveBeenCalled(); + } finally { + probe.mockRestore(); + } + }); + + it('omits user-interaction tools from the admitted headless registry', async () => { + const probe = vi + .spyOn(sandboxPolicy, 'probeShellSandbox') + .mockResolvedValue(); + try { + const config = new Config({ + ...parameters(), + interactive: false, + bareMode: true, + }); + await config.initialize(); + expect( + (ToolRegistry.prototype.registerFactory as Mock).mock.calls.map( + (call) => call[0], + ), + ).toEqual([ + ToolNames.SHELL, + ToolNames.TASK_STOP, + ToolNames.READ_FILE, + ToolNames.WRITE_FILE, + ToolNames.EDIT, + ToolNames.MONITOR, + ToolNames.AGENT, + ToolNames.GLOB, + ToolNames.LS, + ]); + } finally { + probe.mockRestore(); + } + }); + }); + describe('derived Config ownership', () => { + it('preserves the shell sandbox ceiling and rejects relocation before state mutation', async () => { + const policy = { + workspace: path.resolve(TARGET_DIR), + installation: path.resolve('/installation'), + state: path.resolve('/sandbox-state'), + filesystem: 'workspace-write' as const, + network: 'closed' as 'closed' | 'open', + }; + const parent = new Config({ + ...baseParams, + sandbox: undefined, + cwd: TARGET_DIR, + bareMode: false, + interactive: true, + fileCheckpointingEnabled: true, + ideMode: true, + enableManagedAutoMemory: true, + enableManagedAutoDream: true, + enableTeamMemory: true, + enableTeamMemorySync: true, + agentTeamEnabled: true, + workflowsEnabled: true, + sessionWorkflowEnabled: true, + cronEnabled: true, + shellExecutionSandbox: policy, + }); + const snapshot = parent.getShellExecutionSandbox(); + policy.network = 'open'; + expect(snapshot?.network).toBe('closed'); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(parent.getCoreTools()).toEqual([ + ToolNames.SHELL, + ToolNames.TASK_STOP, + ToolNames.READ_FILE, + ToolNames.WRITE_FILE, + ToolNames.EDIT, + ToolNames.MONITOR, + ToolNames.AGENT, + ToolNames.EXEC, + ToolNames.GLOB, + ToolNames.LS, + ToolNames.ASK_USER_QUESTION, + ToolNames.STRUCTURED_OUTPUT, + ]); + expect(parent.getBareMode()).toBe(false); + expect(parent.getIdeMode()).toBe(false); + expect(() => parent.setIdeMode(true)).toThrow('does not support IDE'); + expect(parent.getDisableAllHooks()).toBe(true); + expect(parent.getHookSystem()).toBeUndefined(); + expect(parent.getManagedAutoMemoryEnabled()).toBe(false); + expect(parent.isManagedMemoryAvailable()).toBe(false); + expect(parent.getManagedAutoDreamEnabled()).toBe(false); + expect(parent.getTeamMemoryEnabled()).toBe(false); + expect(parent.getTeamMemorySyncEnabled()).toBe(false); + expect(parent.getAutoSkillEnabled()).toBe(false); + expect(parent.isAgentTeamEnabled()).toBe(false); + expect(parent.isWorkflowsEnabled()).toBe(false); + expect(parent.isSessionWorkflowEnabled()).toBe(false); + expect(parent.isCronEnabled()).toBe(false); + expect(parent.isLspEnabled()).toBe(false); + expect(parent.getFileCheckpointingEnabled()).toBe(false); + expect(() => parent.enableFileCheckpointing()).toThrow('unavailable'); + expect(() => + parent.addMcpServers({ remote: { command: 'node' } }), + ).toThrow('does not support MCP'); + expect(() => + parent.addRuntimeMcpServer('remote', { command: 'node' }), + ).toThrow('does not support MCP'); + await expect( + parent.reinitializeMcpServers({ remote: { command: 'node' } }), + ).rejects.toThrow('does not support MCP'); + expect(parent.getMcpServers()).toEqual({}); + expect(parent.getExtensions()).toEqual([]); + const fileService = parent.getFileSystemService(); + expect(() => parent.setFileSystemService(fileService)).toThrow( + 'delegated filesystem', + ); + expect(parent.getFileSystemService()).toBe(fileService); + const child = deriveWorktreeConfig( + parent, + path.join(TARGET_DIR, 'child'), + ); + expect(child.getShellExecutionSandbox()).toBe(snapshot); + expect(() => deriveWorktreeConfig(parent, '/other')).toThrow( + 'admitted workspace', + ); + await expect(parent.relocateWorkingDirectory('/other')).rejects.toThrow( + 'admitted workspace', + ); + expect(parent.getTargetDir()).toBe(path.resolve(TARGET_DIR)); + }); it('keeps session approval independent of nested agent and worktree modes', () => { const parent = new Config({ ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index eff59c20dce..ecec6ac1982 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -239,6 +239,12 @@ import type { GoalRecoveryRecord } from '../goals/goal-persistence.js'; import { GOAL_DEFAULT_TOKEN_BUDGET } from '../goals/goal-protocol.js'; import { createGoalVerifier } from '../goals/goal-verifier.js'; import type { ToolInvocationGuard } from '../core/tool-invocation-guard.js'; +import type { BwrapPolicy } from '../sandbox/bwrap-execution.js'; +import { + admitShellSandbox, + probeShellSandbox, + assertShellSandboxCwd, +} from '../sandbox/runtime-shell-policy.js'; // Utils import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; @@ -1075,6 +1081,8 @@ export interface ConfigParameters { * before execution. A configured guard fails closed. */ toolInvocationGuard?: ToolInvocationGuard; + /** Internal trusted-host integration; never loaded from workspace settings. */ + shellExecutionSandbox?: Readonly; toolDiscoveryCommand?: string; toolCallCommand?: string; mcpServerCommand?: string; @@ -2501,6 +2509,17 @@ export function deriveConfig( base: Config, overrides: DerivedConfigOverrides = {}, ): Config { + const shellSandbox = base.getShellExecutionSandbox?.(); + if (shellSandbox) { + for (const key of [ + 'getTargetDir', + 'getCwd', + 'getWorkingDir', + 'getProjectRoot', + ] as const) { + assertShellSandboxCwd(shellSandbox, overrides[key]?.() ?? base[key]()); + } + } const derived = Object.create(base) as Config; for (const key in overrides) { if (!Object.hasOwn(overrides, key)) continue; @@ -2519,6 +2538,7 @@ export function deriveConfig( } export class Config { + private readonly shellExecutionSandbox: Readonly | undefined; private sessionId: string; private sessionSourceType?: string; private sessionSourceId?: string; @@ -3043,6 +3063,11 @@ export class Config { ) : undefined; this.sessionRuntimeBaseDir = Storage.getRuntimeBaseDir(); + this.shellExecutionSandbox = admitShellSandbox( + params, + this.sessionRuntimeBaseDir, + Storage.getGlobalQwenDir(), + ); this.provisionalWorkspace = params.provisionalWorkspace === true; this.sessionId = params.sessionId ?? randomUUID(); // Only set the global env marker once per process lifetime, so @@ -3720,6 +3745,8 @@ export class Config { options?: ConfigInitializeOptions, ): Promise { try { + if (this.shellExecutionSandbox) + await probeShellSandbox(this.shellExecutionSandbox, options?.signal); const activation = this.activateChatRecording(); this.sessionWriterActivationPromise = activation; try { @@ -3772,7 +3799,6 @@ export class Config { this.debugLogger.info('Config initialization started'); await this.proxyDispatcherReady; options?.signal?.throwIfAborted(); - // Omni multimodal support declares ffmpeg/ffprobe as hard runtime // prerequisites: fail fast at startup with an actionable message // instead of erroring midway through the first video interaction. @@ -3791,7 +3817,7 @@ export class Config { await assertOmniRuntimeDependencies(); } - if (options?.skipFileCheckpointing === true) { + if (options?.skipFileCheckpointing === true || this.shellExecutionSandbox) { this.fileCheckpointingEnabled = false; this.fileHistoryService = undefined; } @@ -3811,9 +3837,17 @@ export class Config { (n) => n.trim() !== '' && n.toLowerCase() !== 'none', ); recordStartupEvent('config_initialize_extensions_initial_start'); - if (!this.isSafeMode() && !this.getBareMode()) { + if ( + !this.shellExecutionSandbox && + !this.isSafeMode() && + !this.getBareMode() + ) { await this.extensionManager.refreshCache(); - } else if (!this.isSafeMode() && explicitExtensionNames.length > 0) { + } else if ( + !this.shellExecutionSandbox && + !this.isSafeMode() && + explicitExtensionNames.length > 0 + ) { await this.extensionManager.refreshCache({ names: explicitExtensionNames, }); @@ -4175,7 +4209,11 @@ export class Config { } } this.skillManager = new SkillManager(this); - if (this.getBareMode() || this.isSafeMode()) { + if ( + this.shellExecutionSandbox || + this.getBareMode() || + this.isSafeMode() + ) { await this.skillManager.refreshCache(); } else { await this.skillManager.startWatching(); @@ -4242,7 +4280,11 @@ export class Config { } recordStartupEvent('config_initialize_extensions_final_start'); - if (!this.getBareMode() && !this.isSafeMode()) { + if ( + !this.shellExecutionSandbox && + !this.getBareMode() && + !this.isSafeMode() + ) { await this.extensionManager.refreshCache(); } recordStartupEvent('config_initialize_extensions_final_end'); @@ -4348,6 +4390,7 @@ export class Config { // also respects the `allowedMcpServers` filter already applied there. const hasMcpServers = Object.keys(this.getMcpServers() ?? {}).length > 0; if ( + !this.shellExecutionSandbox && skipInlineMcpDiscovery && (!(this.getBareMode() || this.isSafeMode()) || hasMcpServers) && !this.provisionalWorkspace && @@ -4377,7 +4420,11 @@ export class Config { // directly would cause launches from a monorepo subdirectory to // scan `/.qwen/worktrees/` — which never exists — and the // sweep would silently be a no-op forever. - if (!this.getBareMode() && !this.provisionalWorkspace) { + if ( + !this.shellExecutionSandbox && + !this.getBareMode() && + !this.provisionalWorkspace + ) { void (async () => { try { // Resolve the repo top-level FIRST. The previous code bailed @@ -6771,6 +6818,9 @@ export class Config { memoryRefreshError?: unknown; mcpRefreshError?: unknown; }> { + if (this.shellExecutionSandbox) { + assertShellSandboxCwd(this.shellExecutionSandbox, path.resolve(newDir)); + } if (isDerivedConfig(this)) { throw new Error('Derived Configs cannot relocate working directories'); } @@ -7137,6 +7187,21 @@ export class Config { /** @deprecated Use getPermissionsAllow() instead. */ getCoreTools(): string[] | undefined { + if (this.shellExecutionSandbox) + return [ + ToolNames.SHELL, + ToolNames.TASK_STOP, + ToolNames.READ_FILE, + ToolNames.WRITE_FILE, + ToolNames.EDIT, + ToolNames.MONITOR, + ToolNames.AGENT, + ToolNames.EXEC, + ToolNames.GLOB, + ToolNames.LS, + ToolNames.ASK_USER_QUESTION, + ToolNames.STRUCTURED_OUTPUT, + ]; if (this.getBareMode()) { return DEFAULT_BARE_CORE_TOOLS; } @@ -7460,6 +7525,7 @@ export class Config { } getMcpServers(): Record | undefined { + if (this.shellExecutionSandbox) return {}; // Safe mode distrusts LOCAL/ambient state (settings.json, extensions, // project `.mcp.json`) — not the caller's own explicit, per-invocation // request. `topTierMcpServers` (ACP `session/new`'s `mcpServers` field, @@ -7548,6 +7614,8 @@ export class Config { } addMcpServers(servers: Record): void { + if (this.shellExecutionSandbox && Object.keys(servers).length) + throw new Error('Tool execution sandbox does not support MCP servers.'); if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); } @@ -7562,6 +7630,8 @@ export class Config { * {@link getMcpServers} still layers them on top. See sub-task 3. */ setMcpServers(servers: Record | undefined): void { + if (this.shellExecutionSandbox && Object.keys(servers ?? {}).length) + throw new Error('Tool execution sandbox does not support MCP servers.'); this.mcpServers = servers; } @@ -7703,6 +7773,7 @@ export class Config { } private async refreshMcpServers(): Promise { + if (this.shellExecutionSandbox) return; if (!this.initialized) { // No tool registry yet — boot-time discovery will pick up the new map. this.debugLogger.debug( @@ -7776,6 +7847,8 @@ export class Config { * of the settings layer (Task 5). */ addRuntimeMcpServer(name: string, config: MCPServerConfig): void { + if (this.shellExecutionSandbox) + throw new Error('Tool execution sandbox does not support MCP servers.'); this.runtimeMcpServers.set(name, config); } @@ -7806,7 +7879,12 @@ export class Config { } isLspEnabled(): boolean { - return this.lspEnabled && !this.getBareMode() && !this.provisionalWorkspace; + return ( + this.lspEnabled && + !this.shellExecutionSandbox && + !this.getBareMode() && + !this.provisionalWorkspace + ); } getLspClient(): LspClient | undefined { @@ -7861,6 +7939,8 @@ export class Config { * Allows wiring an LSP client after Config construction but before initialize(). */ setLspClient(client: LspClient | undefined): void { + if (this.shellExecutionSandbox && client) + throw new Error('Tool execution sandbox does not support LSP.'); if (this.initialized) { throw new Error('Cannot set LSP client after initialization'); } @@ -8006,6 +8086,8 @@ export class Config { } setArenaManager(manager: ArenaManager | null): void { + if (this.shellExecutionSandbox && manager) + throw new Error('Tool execution sandbox does not support agent arenas.'); this.arenaManager = manager; this.arenaManagerChangeCallback?.(manager); } @@ -8035,6 +8117,8 @@ export class Config { } setTeamManager(manager: TeamManager | null): void { + if (this.shellExecutionSandbox && manager) + throw new Error('Tool execution sandbox does not support agent teams.'); this.teamManager = manager; for (const cb of this.teamManagerChangeCallbacks) { cb(manager); @@ -8868,6 +8952,7 @@ export class Config { } isCronEnabled(): boolean { + if (this.shellExecutionSandbox) return false; if (process.env['QWEN_CODE_DISABLE_CRON'] === '1') return false; return this.cronEnabled; } @@ -8897,12 +8982,14 @@ export class Config { } isAgentTeamEnabled(): boolean { + if (this.shellExecutionSandbox) return false; // Agent team is experimental and opt-in: enabled via settings or env var if (process.env['QWEN_CODE_ENABLE_AGENT_TEAM'] === '1') return true; return this.agentTeamEnabled; } isArtifactEnabled(): boolean { + if (this.shellExecutionSandbox) return false; // Publishing writes outside the project and opens a browser, so it is // limited to interactive or managed preview sessions, excluding SDK use. // Managed previews render in Web Shell instead of opening a host browser. @@ -9121,6 +9208,7 @@ export class Config { } isImageGenerationEnabled(): boolean { + if (this.shellExecutionSandbox) return false; return this.getImageGenerationConfig() !== undefined; } @@ -9131,6 +9219,7 @@ export class Config { } isWorkflowsEnabled(): boolean { + if (this.shellExecutionSandbox) return false; if (this.provisionalWorkspace) return false; // Workflows are opt-in via settings, env, or the bundled review skill. // P1 also honors a kill switch: QWEN_CODE_DISABLE_WORKFLOWS=1 forces off @@ -9145,6 +9234,7 @@ export class Config { async enableReviewWorkflow(): Promise { if ( + this.shellExecutionSandbox || this.workflowsEnabled === false || !isTopLevelSession() || this.getBareMode() || @@ -9173,6 +9263,7 @@ export class Config { * destroying it. */ isSessionWorkflowEnabled(): boolean { + if (this.shellExecutionSandbox) return false; return ( this.sessionWorkflowEnabledProvider?.() ?? this.sessionWorkflowEnabled ); @@ -9510,10 +9601,14 @@ export class Config { } getFileCheckpointingEnabled(): boolean { - return this.fileCheckpointingEnabled; + return !this.shellExecutionSandbox && this.fileCheckpointingEnabled; } enableFileCheckpointing(): void { + if (this.shellExecutionSandbox) + throw new Error( + 'File checkpointing is unavailable with tools.executionSandbox.', + ); this.fileCheckpointingEnabled = true; this.fileHistoryService = undefined; } @@ -9522,7 +9617,7 @@ export class Config { if (!this.fileHistoryService) { const service = new FileHistoryService( this.sessionId, - this.fileCheckpointingEnabled, + this.getFileCheckpointingEnabled(), this.cwd, (snapshot) => { if (this.fileHistoryService !== service) return; @@ -9631,6 +9726,7 @@ export class Config { * Returns undefined if hooks are not enabled. */ getHookSystem(): HookSystem | undefined { + if (this.shellExecutionSandbox) return undefined; return this.hookSystem; } @@ -9652,6 +9748,7 @@ export class Config { * Check if all hooks are disabled. */ getDisableAllHooks(): boolean { + if (this.shellExecutionSandbox) return true; return this.disableAllHooks || this.getBareMode() || this.isSafeMode(); } @@ -9660,6 +9757,7 @@ export class Config { } getManagedAutoMemoryEnabled(): boolean { + if (this.shellExecutionSandbox) return false; return ( this.enableManagedAutoMemory && !this.getBareMode() && !this.isSafeMode() ); @@ -9671,6 +9769,7 @@ export class Config { * for tests / power users ('0' forces off, '1' forces on). */ getTeamMemoryEnabled(): boolean { + if (this.shellExecutionSandbox) return false; if (this.getBareMode() || this.provisionalWorkspace) { return false; } @@ -9691,6 +9790,7 @@ export class Config { * Off by default since it mutates the repo and pushes. Inert in bare mode. */ getTeamMemorySyncEnabled(): boolean { + if (this.shellExecutionSandbox) return false; if (this.getBareMode() || this.provisionalWorkspace) { return false; } @@ -9705,16 +9805,19 @@ export class Config { } isManagedMemoryAvailable(): boolean { + if (this.shellExecutionSandbox) return false; return this.enableManagedAutoMemory && !this.getBareMode(); } getManagedAutoDreamEnabled(): boolean { + if (this.shellExecutionSandbox) return false; return ( this.enableManagedAutoDream && !this.getBareMode() && !this.isSafeMode() ); } getAutoSkillEnabled(): boolean { + if (this.shellExecutionSandbox) return false; return ( this.enableAutoSkill && !this.getBareMode() && @@ -9923,6 +10026,7 @@ export class Config { } getExtensions(): Extension[] { + if (this.shellExecutionSandbox) return []; const extensions = this.extensionManager.getLoadedExtensions(); if (this.overrideExtensions) { const overrideExtensionNames = new Set( @@ -9980,7 +10084,7 @@ export class Config { } getIdeMode(): boolean { - return this.ideMode; + return !this.shellExecutionSandbox && this.ideMode; } getFolderTrustFeature(): boolean { @@ -10025,6 +10129,10 @@ export class Config { } setIdeMode(value: boolean): void { + if (this.shellExecutionSandbox && value) + throw new Error( + 'Tool execution sandbox does not support IDE integration.', + ); this.ideMode = value; } @@ -10093,6 +10201,11 @@ export class Config { * Set a custom FileSystemService */ setFileSystemService(fileSystemService: FileSystemService): void { + if (this.shellExecutionSandbox) { + throw new Error( + 'Internal sandbox does not support delegated filesystem services.', + ); + } this.fileSystemService = fileSystemService; } @@ -10921,6 +11034,10 @@ export class Config { return this.toolInvocationGuard; } + getShellExecutionSandbox(): Readonly | undefined { + return this.shellExecutionSandbox; + } + /** * Returns the callback for persisting permission rules to settings files. * Returns undefined if no callback was provided (e.g. SDK mode). @@ -11172,6 +11289,56 @@ export class Config { }); }; + if (this.shellExecutionSandbox) { + await registerLazy(ToolNames.SHELL, async () => { + const { ShellTool } = await import('../tools/shell.js'); + return new ShellTool(this); + }); + await registerLazy(ToolNames.TASK_STOP, async () => { + const { TaskStopTool } = await import('../tools/task-stop.js'); + return new TaskStopTool(this); + }); + await registerLazy(ToolNames.READ_FILE, async () => { + const { ReadFileTool } = await import('../tools/read-file.js'); + return new ReadFileTool(this); + }); + await registerLazy(ToolNames.WRITE_FILE, async () => { + const { WriteFileTool } = await import('../tools/write-file.js'); + return new WriteFileTool(this); + }); + await registerLazy(ToolNames.EDIT, async () => { + const { EditTool } = await import('../tools/edit.js'); + return new EditTool(this); + }); + await registerLazy(ToolNames.MONITOR, async () => { + const { MonitorTool } = await import('../tools/monitor.js'); + return new MonitorTool(this); + }); + await registerLazy(ToolNames.AGENT, async () => { + const { AgentTool } = await import('../tools/agent/agent.js'); + return new AgentTool(this); + }); + await registerLazy(ToolNames.GLOB, async () => { + const { GlobTool } = await import('../tools/glob.js'); + return new GlobTool(this); + }); + await registerLazy(ToolNames.LS, async () => { + const { LSTool } = await import('../tools/ls.js'); + return new LSTool(this); + }); + if (resolveInteractionMode(this) !== 'headless') { + await registerLazy(ToolNames.ASK_USER_QUESTION, async () => { + const { AskUserQuestionTool } = await import( + '../tools/askUserQuestion.js' + ); + return new AskUserQuestionTool(this); + }); + } + await registerExecIfEnabled(); + await registerStructuredOutputIfRequested(); + return registry; + } + if (this.getBareMode()) { await registerLazy(ToolNames.READ_FILE, async () => { const { ReadFileTool } = await import('../tools/read-file.js'); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 836195300d4..b508b57ffd8 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -3834,6 +3834,29 @@ describe('Gemini Client (client.ts)', () => { }); describe('getMainSessionSystemInstruction', () => { + it('skips host Git snapshots throughout a sandboxed shell session', async () => { + mockConfig.getShellExecutionSandbox = vi.fn().mockReturnValue({ + workspace: '/test/project/root', + installation: '/test/installation', + state: '/test/state', + filesystem: 'workspace-write', + network: 'closed', + }); + vi.mocked(getRecentGitStatus).mockClear(); + vi.mocked(getRecentGitStatus).mockReturnValue('Host Git snapshot'); + + await client.startChat(); + await client.addWorkingDirectoryChangedContext( + '/test/project/root', + '/test/project/root/subdir', + ); + + expect(getRecentGitStatus).not.toHaveBeenCalled(); + expect( + client.getChat()['generationConfig'].systemInstruction, + ).not.toContain('Host Git snapshot'); + }); + it('records the gitStatus-free base as the static system prefix on Config', () => { vi.mocked(getCoreSystemPrompt).mockReturnValueOnce('core base prompt'); vi.mocked(getRecentGitStatus).mockReturnValueOnce('Git snapshot A'); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index bede9b4d5fb..2073b4c3a95 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1625,6 +1625,10 @@ export class LlmClient { } private getCachedGitStatus(): string | null { + if (this.config.getShellExecutionSandbox?.()) { + // Even git status can execute repository-configured filters on the host. + return null; + } if (this.cachedGitStatus === undefined) { // Mirror claude-code: append git status (branch + recent commits) to the // system prompt so the main agent treats version history as authoritative diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index eebe020d45c..074ab5e9165 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -64,6 +64,16 @@ afterEach(() => { }); describe('startSpeculation', () => { + it('rejects tool sandbox sessions before starting host speculation', async () => { + const config = { + getShellExecutionSandbox: () => ({}), + } as unknown as Config; + await expect(startSpeculation(config, 'write a.ts')).rejects.toThrow( + 'unavailable with tools.executionSandbox', + ); + expect(forkedAgentMocks.getCacheSafeParams).not.toHaveBeenCalled(); + expect(forkedAgentMocks.runForkedAgent).not.toHaveBeenCalled(); + }); it('does not start when the session-scoped lookup returns null', async () => { const config = { getSessionId: vi.fn().mockReturnValue('spec-session'), diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 7220e207206..9c8b0ebdc4a 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -141,6 +141,11 @@ export async function startSpeculation( parentSignal?: AbortSignal, options?: { model?: string }, ): Promise { + if (config.getShellExecutionSandbox?.()) { + throw new Error( + 'Speculative execution is unavailable with tools.executionSandbox.', + ); + } const cacheSafe = getCacheSafeParams(config.getSessionId()); if (!cacheSafe) { throw new Error('CacheSafeParams not available for speculation'); diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index 2c357a87274..aa93b31be8e 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -1859,6 +1859,17 @@ function makeConfig( describe('PermissionManager', () => { let pm: PermissionManager; + it('does not implicitly allow read-only shell commands under internal sandbox policy', async () => { + const manager = new PermissionManager({ + ...makeConfig(), + getShellExecutionSandbox: () => ({}), + }); + manager.initialize(); + expect(await manager.isCommandAllowed('git status && ls', '/project')).toBe( + 'ask', + ); + }); + describe('basic rule evaluation', () => { beforeEach(() => { pm = new PermissionManager( diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 8225283e51e..bf24807d2a4 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -147,6 +147,7 @@ function splitCommandForRules(command: string, toolName: string): string[] { * PermissionManager therefore only needs these three getters. */ export interface PermissionManagerConfig { + getShellExecutionSandbox?(): unknown; /** Merged allow-rules (settings + coreTools + allowedTools). */ getPermissionsAllow(): string[] | undefined; /** Merged ask-rules (settings only). */ @@ -687,6 +688,7 @@ export class PermissionManager { command: string, cwd?: string, ): Promise<'allow' | 'ask'> { + if (this.config.getShellExecutionSandbox?.()) return 'ask'; try { const isReadOnly = cwd ? await isShellCommandReadOnlyASTInDirectory(command, cwd) diff --git a/packages/core/src/sandbox/bwrap-execution.test.ts b/packages/core/src/sandbox/bwrap-execution.test.ts index 6895b618054..ac75eba854b 100644 --- a/packages/core/src/sandbox/bwrap-execution.test.ts +++ b/packages/core/src/sandbox/bwrap-execution.test.ts @@ -13,6 +13,7 @@ import { readdirSync, realpathSync, rmSync, + statSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; @@ -56,6 +57,8 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { let state: string; let bwrap: string; let originalTmpdir: string | undefined; + let capturedPayloadEnv: Record; + let capturedPayloadEnvMode: number; beforeEach(() => { vi.restoreAllMocks(); @@ -71,6 +74,8 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { writeFileSync(bwrap, 'stub'); originalTmpdir = process.env['TMPDIR']; delete process.env['TMPDIR']; + capturedPayloadEnv = {}; + capturedPayloadEnvMode = 0; }); afterEach(() => { @@ -104,6 +109,11 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { .spyOn(ShellExecutionService, 'executeLaunch') .mockImplementation(async (launch) => { const statusPath = launch.args[2]; + const payloadEnvPath = launch.args[3]; + capturedPayloadEnv = JSON.parse( + readFileSync(payloadEnvPath, 'utf8'), + ) as Record; + capturedPayloadEnvMode = statSync(payloadEnvPath).mode & 0o777; if (receipt) writeFileSync(statusPath, JSON.stringify(receipt)); return { pid: 123, @@ -120,10 +130,11 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { }; }); - it('builds a literal launch, filters internal secrets, and cleans confirmed resources', async () => { + it('builds a literal launch, transports the payload env privately, and prepares writable masks', async () => { + const maskedPath = path.join(workspace, '.qwen', 'review-leases'); const launch = mockLaunch({ state: 'confirmed', exitCode: 0 }); const handle = await executeBwrap( - policy(), + { ...policy(), maskedPaths: [maskedPath] }, payload(), () => {}, new AbortController().signal, @@ -133,28 +144,75 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { }); const relayLaunch = launch.mock.calls[0][0]; const argv = relayLaunch.args; + const admittedWorkspace = realpathSync(workspace); + const admittedMaskedPath = path.join( + admittedWorkspace, + '.qwen', + 'review-leases', + ); expect(argv).toContain('--unshare-pid'); expect(argv).toContain('--unshare-net'); - expect(argv).toContain('--clearenv'); - expect(argv).toContain('GH_TOKEN'); - expect(argv).toContain('visible-user-value'); + expect(argv).not.toContain('--clearenv'); + expect(argv).not.toContain('--setenv'); + expect(argv).not.toContain('GH_TOKEN'); + expect(argv).not.toContain('visible-user-value'); expect(argv).not.toContain('QWEN_SERVER_TOKEN'); expect(argv).not.toContain('internal-secret'); - const term = argv.indexOf('TERM'); - expect(argv.slice(term - 1, term + 2)).toEqual([ - '--setenv', - 'TERM', - 'xterm-256color', - ]); - const scratch = argv[argv.indexOf('TMPDIR') + 1]; + expect(capturedPayloadEnv).toMatchObject({ + GH_TOKEN: 'visible-user-value', + TERM: 'xterm-256color', + }); + expect(capturedPayloadEnv).not.toHaveProperty('QWEN_SERVER_TOKEN'); + expect(capturedPayloadEnvMode).toBe(0o600); + const workspaceBind = argv.findIndex( + (value, index) => + value === '--bind' && + argv[index + 1] === admittedWorkspace && + argv[index + 2] === admittedWorkspace, + ); + const mask = argv.indexOf('--tmpfs'); + expect(workspaceBind).toBeGreaterThan(-1); + expect(argv.slice(mask, mask + 2)).toEqual(['--tmpfs', admittedMaskedPath]); + expect(mask).toBeGreaterThan(workspaceBind); + expect(existsSync(maskedPath)).toBe(true); + const scratch = capturedPayloadEnv['TMPDIR']; expect(path.isAbsolute(scratch)).toBe(true); expect(existsSync(scratch)).toBe(false); expect(readdirSync(state)).toEqual([]); }); + it('skips absent masks without mutating a read-only workspace', async () => { + const maskedPath = path.join(workspace, '.qwen', 'review-leases'); + const launch = mockLaunch({ state: 'confirmed', exitCode: 0 }); + const handle = await executeBwrap( + { + ...policy(), + filesystem: 'read-only', + maskedPaths: [maskedPath], + }, + payload(), + () => {}, + new AbortController().signal, + ); + await handle.result; + expect(launch.mock.calls[0][0].args).not.toContain('--tmpfs'); + expect(existsSync(maskedPath)).toBe(false); + }); + + it('rejects mask paths outside the workspace', async () => { + await expect( + executeBwrap( + { ...policy(), maskedPaths: [state] }, + payload(), + () => {}, + new AbortController().signal, + ), + ).rejects.toThrow('inside the workspace'); + }); + it('uses an absolute scratch root for a relative TMPDIR without leaking it', async () => { process.env['TMPDIR'] = 'relative-tmp'; - const launch = mockLaunch({ state: 'confirmed', exitCode: 0 }); + mockLaunch({ state: 'confirmed', exitCode: 0 }); const handle = await executeBwrap( policy(), payload(), @@ -162,8 +220,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { new AbortController().signal, ); await handle.result; - const argv = launch.mock.calls[0][0].args; - const scratch = argv[argv.indexOf('TMPDIR') + 1]; + const scratch = capturedPayloadEnv['TMPDIR']; expect(path.dirname(scratch)).toBe(realpathSync('/tmp')); expect(existsSync(scratch)).toBe(false); expect(existsSync(path.join(process.cwd(), 'relative-tmp'))).toBe(false); @@ -181,7 +238,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { it('allows a temporary root that contains disjoint sandbox roots', async () => { process.env['TMPDIR'] = root; - const launch = mockLaunch({ state: 'confirmed', exitCode: 0 }); + mockLaunch({ state: 'confirmed', exitCode: 0 }); const handle = await executeBwrap( policy(), payload(), @@ -189,8 +246,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { new AbortController().signal, ); await handle.result; - const argv = launch.mock.calls[0][0].args; - const scratch = argv[argv.indexOf('TMPDIR') + 1]; + const scratch = capturedPayloadEnv['TMPDIR']; expect(path.dirname(scratch)).toBe(realpathSync(root)); expect(existsSync(scratch)).toBe(false); }); @@ -208,7 +264,7 @@ describe.skipIf(process.platform === 'win32')('bwrap execution adapter', () => { }); const argv = launch.mock.calls[0][0].args; const statusPath = argv[2]; - const scratch = argv[argv.indexOf('TMPDIR') + 1]; + const scratch = capturedPayloadEnv['TMPDIR']; expect(readFileSync(statusPath, 'utf8')).toContain('unconfirmed'); expect(existsSync(scratch)).toBe(true); rmSync(path.dirname(statusPath), { recursive: true, force: true }); diff --git a/packages/core/src/sandbox/bwrap-execution.ts b/packages/core/src/sandbox/bwrap-execution.ts index 45be92bf9e9..0cd8970a676 100644 --- a/packages/core/src/sandbox/bwrap-execution.ts +++ b/packages/core/src/sandbox/bwrap-execution.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { existsSync, realpathSync, statSync } from 'node:fs'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { existsSync, mkdirSync, realpathSync, statSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveBundleDir } from '../utils/bundlePaths.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { isSubpath, realpathNearestExisting } from '../utils/paths.js'; import { isInternalSecretEnvVar } from '../utils/sanitize-child-env.js'; import { ShellExecutionService, @@ -32,6 +33,7 @@ export interface BwrapPolicy { workspace: string; installation: string; state: string; + maskedPaths?: readonly string[]; filesystem: 'read-only' | 'workspace-write'; network: 'open' | 'closed'; bwrapPath?: string; @@ -64,16 +66,7 @@ export function sandboxAsset(name: 'bwrap-relay' | 'file-worker'): string { return realpathSync(asset); } -const contains = (parent: string, child: string) => { - const relative = path.relative(parent, child); - return ( - relative === '' || - (relative !== '..' && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative)) - ); -}; -const overlaps = (a: string, b: string) => contains(a, b) || contains(b, a); +const overlaps = (a: string, b: string) => isSubpath(a, b) || isSubpath(b, a); const directory = (value: string) => { if (!path.isAbsolute(value)) throw new Error('Sandbox paths must be absolute.'); @@ -136,7 +129,7 @@ export async function executeBwrap( ]; const checkWritable = (root: string) => { if ( - contains(root, realpathSync(os.homedir())) || + isSubpath(root, realpathSync(os.homedir())) || protectedRoots.some((protectedRoot) => overlaps(root, protectedRoot)) ) { throw new Error('Writable directory overlaps a protected root.'); @@ -145,8 +138,16 @@ export async function executeBwrap( // Keep this admission invariant even for read-only profiles so a later profile // change cannot turn a trusted installation/state directory into a workspace. checkWritable(workspace); + const maskedPaths = (policy.maskedPaths ?? []).map((value) => { + if (!path.isAbsolute(value)) + throw new Error('Sandbox mask paths must be absolute.'); + const resolved = realpathNearestExisting(value); + if (resolved === workspace || !isSubpath(workspace, resolved)) + throw new Error('Sandbox mask paths must remain inside the workspace.'); + return resolved; + }); const cwd = directory(payload.cwd); - if (!contains(workspace, cwd)) + if (!isSubpath(workspace, cwd)) throw new Error('Payload cwd must be inside the workspace.'); const executable = payload.executable; const args = [...payload.args]; @@ -180,9 +181,9 @@ export async function executeBwrap( ? realpathSync(requestedScratchRoot) : realpathSync('/tmp'); if ( - contains(workspace, scratchRoot) || + isSubpath(workspace, scratchRoot) || protectedRoots.some((protectedRoot) => - contains(protectedRoot, scratchRoot), + isSubpath(protectedRoot, scratchRoot), ) ) throw new Error( @@ -193,6 +194,29 @@ export async function executeBwrap( checkWritable(scratch); if (overlaps(workspace, scratch)) throw new Error('Workspace and scratch must be disjoint.'); + const payloadEnv = { + ...env, + PWD: cwd, + TMPDIR: scratch, + TMP: scratch, + TEMP: scratch, + TERM: env['TERM'] || 'xterm-256color', + }; + for (const [key, value] of Object.entries(payloadEnv)) { + if ( + !key || + key.includes('=') || + key.includes('\0') || + value.includes('\0') + ) + throw new Error('Invalid payload environment.'); + } + const payloadEnvPath = path.join(control, 'payload-env.json'); + await writeFile(payloadEnvPath, JSON.stringify(payloadEnv), { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); const bwrapArgs = [ '--ro-bind', '/', @@ -203,32 +227,19 @@ export async function executeBwrap( '--dev', '/dev', '--die-with-parent', - '--clearenv', ]; - for (const [key, value] of Object.entries({ - ...env, - PWD: cwd, - TMPDIR: scratch, - TMP: scratch, - TEMP: scratch, - // `--clearenv` wipes everything before these --setenv apply, so a - // payload env without TERM would run with TERM unset (ncurses: - // "unknown terminal type"). The relay bootstrap TERM default never - // reaches the payload; the default belongs here. - TERM: env['TERM'] || 'xterm-256color', - })) { - if ( - !key || - key.includes('=') || - key.includes('\0') || - value.includes('\0') - ) - throw new Error('Invalid payload environment.'); - bwrapArgs.push('--setenv', key, value); - } bwrapArgs.push('--bind', scratch, scratch); if (filesystem === 'workspace-write') bwrapArgs.push('--bind', workspace, workspace); + for (const maskedPath of maskedPaths) { + if (!existsSync(maskedPath)) { + if (filesystem === 'read-only') continue; + mkdirSync(maskedPath, { recursive: true }); + } + if (!statSync(maskedPath).isDirectory()) + throw new Error('Sandbox mask paths must be directories.'); + bwrapArgs.push('--tmpfs', maskedPath); + } if (network === 'closed') bwrapArgs.push('--unshare-net'); bwrapArgs.push('--chdir', cwd, '--', executable, ...args); const statusPath = path.join(control, 'status.json'); @@ -322,7 +333,14 @@ export async function executeBwrap( const handle = await ShellExecutionService.executeLaunch( { executable: node, - args: [relay, String(process.pid), statusPath, bwrap, ...bwrapArgs], + args: [ + relay, + String(process.pid), + statusPath, + payloadEnvPath, + bwrap, + ...bwrapArgs, + ], cwd, env: { PATH: '/usr/bin:/bin', diff --git a/packages/core/src/sandbox/bwrap-relay.ts b/packages/core/src/sandbox/bwrap-relay.ts index 256f3d0d084..f03d80c8ad2 100644 --- a/packages/core/src/sandbox/bwrap-relay.ts +++ b/packages/core/src/sandbox/bwrap-relay.ts @@ -5,11 +5,19 @@ */ import { spawn } from 'node:child_process'; -import { constants, openSync, writeFileSync, closeSync } from 'node:fs'; +import { + closeSync, + constants, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import type { Readable } from 'node:stream'; import { MAX_STATUS_BYTES, parseBwrapStatus } from './bwrap-status.js'; -const [parentPid, statusPath, bwrap, ...args] = process.argv.slice(2); +const [parentPid, statusPath, payloadEnvPath, bwrap, ...args] = + process.argv.slice(2); if (process.ppid !== Number(parentPid)) process.exit(1); const parentWatch = setInterval(() => { if (process.ppid !== Number(parentPid)) process.exit(1); @@ -23,9 +31,28 @@ const fd = openSync( constants.O_NOFOLLOW, 0o600, ); +const envFd = openSync( + payloadEnvPath, + constants.O_RDONLY | constants.O_NOFOLLOW, +); +let parsedEnv: unknown; +try { + parsedEnv = JSON.parse(readFileSync(envFd, 'utf8')); +} finally { + closeSync(envFd); + unlinkSync(payloadEnvPath); +} +if (!parsedEnv || typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) + process.exit(1); +const env = Object.fromEntries( + Object.entries(parsedEnv).map(([key, value]) => { + if (typeof value !== 'string') process.exit(1); + return [key, value]; + }), +); const child = spawn(bwrap, ['--json-status-fd', '3', ...args], { stdio: ['inherit', 'inherit', 'inherit', 'pipe'], - env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', TERM: 'xterm-256color' }, + env, }); let wire = ''; let bytes = 0; diff --git a/packages/core/src/sandbox/file-worker-client.test.ts b/packages/core/src/sandbox/file-worker-client.test.ts index b5be38f2b41..9fe0057a64a 100644 --- a/packages/core/src/sandbox/file-worker-client.test.ts +++ b/packages/core/src/sandbox/file-worker-client.test.ts @@ -77,6 +77,15 @@ describe('sandbox file worker client', () => { writeSandboxFile(policy, request, new AbortController().signal), ).resolves.toBeUndefined(); }); + it.each([ + ['invalid JSON', 'not json'], + ['negative reply', '{"ok":false,"error":"partial write"}'], + ])('rejects a zero exit with %s', async (_name, reply) => { + result({}, reply); + await expect( + writeSandboxFile(policy, request, new AbortController().signal), + ).rejects.toThrow(); + }); it('reports a confirmed nonzero exit before parsing an invalid reply', async () => { result( { diff --git a/packages/core/src/sandbox/file-worker-client.ts b/packages/core/src/sandbox/file-worker-client.ts index 5eef34613fe..0ff242032e7 100644 --- a/packages/core/src/sandbox/file-worker-client.ts +++ b/packages/core/src/sandbox/file-worker-client.ts @@ -62,15 +62,17 @@ export async function writeSandboxFile( `Sandbox file write failed (${result.sandboxStatus.state}): ${detail}`, ); } - if (result.sandboxStatus.exitCode === 0) return; let parsedReply: Record | undefined; try { parsedReply = JSON.parse(reply.trim()) as Record; } catch { + if (result.sandboxStatus.exitCode === 0) + throw new Error('Invalid sandbox file worker reply.'); throw new Error( `Sandbox file worker exited ${result.sandboxStatus.exitCode}: ${diagnostics || result.output || reply || 'invalid reply'}`, ); } + if (result.sandboxStatus.exitCode === 0 && parsedReply['ok'] === true) return; if (parsedReply['ok'] === false && typeof parsedReply['error'] === 'string') { throw Object.assign(new Error(parsedReply['error']), { ...(typeof parsedReply['code'] === 'string' diff --git a/packages/core/src/sandbox/runtime-file.test.ts b/packages/core/src/sandbox/runtime-file.test.ts new file mode 100644 index 00000000000..02b771f6cf2 --- /dev/null +++ b/packages/core/src/sandbox/runtime-file.test.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import type { Config } from '../config/config.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; +import { FileReadCache } from '../services/fileReadCache.js'; +import { WriteFileTool } from '../tools/write-file.js'; +import { EditTool } from '../tools/edit.js'; +import { ToolErrorType } from '../tools/tool-error.js'; +import { captureRuntimeFileVersion, writeRuntimeFile } from './runtime-file.js'; +import { writeSandboxFile } from './file-worker-client.js'; +import { getSandboxFileVersion } from './file-version.js'; + +vi.mock('./file-worker-client.js', () => ({ writeSandboxFile: vi.fn() })); +vi.mock('../telemetry/loggers.js', () => ({ logFileOperation: vi.fn() })); + +describe('runtime file mutation routing', () => { + let root: string; + let service: StandardFileSystemService; + let cache: FileReadCache; + let config: Config; + const trackEdit = vi.fn(); + beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(writeSandboxFile).mockReset(); + trackEdit.mockReset(); + root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-file-')), + ); + service = new StandardFileSystemService(); + cache = new FileReadCache(); + config = { + getShellExecutionSandbox: () => ({ + workspace: root, + installation: '/installation', + state: '/state', + filesystem: 'workspace-write', + network: 'closed', + }), + getTargetDir: () => root, + getProjectRoot: () => root, + getFileSystemService: () => service, + getFileReadCache: () => cache, + getFileReadCacheDisabled: () => false, + getFileHistoryService: () => ({ trackEdit }), + getDefaultFileEncoding: () => 'utf-8', + getDebugMode: () => false, + isRecordArtifactEnabled: () => false, + } as unknown as Config; + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('retains the ordinary delegated filesystem and provenance without policy', async () => { + vi.spyOn(config, 'getShellExecutionSandbox').mockReturnValue(undefined); + const write = vi.spyOn(service, 'writeTextFile').mockResolvedValue({}); + const params = { + path: path.join(root, 'a'), + content: 'text', + toolWriteOrigin: 'edit' as const, + }; + expect(captureRuntimeFileVersion(config, params.path)).toBeUndefined(); + await writeRuntimeFile( + config, + params, + undefined, + new AbortController().signal, + ); + expect(write).toHaveBeenCalledWith(params); + expect(writeSandboxFile).not.toHaveBeenCalled(); + }); + + it('encodes BOM/CRLF bytes for the worker without a host write', async () => { + const write = vi.spyOn(service, 'writeTextFile'); + await writeRuntimeFile( + config, + { + path: path.join(root, 'a'), + content: '世界\nline', + _meta: { bom: true, lineEnding: 'crlf' }, + }, + null, + new AbortController().signal, + ); + expect(vi.mocked(writeSandboxFile).mock.calls[0][1].content).toEqual( + Buffer.from('\ufeff世界\r\nline'), + ); + expect(write).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(root, 'a'))).toBe(false); + }); + + it.each(['write', 'edit'] as const)( + 'does not create host directories or retry a failed %s', + async (kind) => { + const file = path.join(root, 'absent', 'nested', 'file'); + const write = vi.spyOn(service, 'writeTextFile'); + vi.mocked(writeSandboxFile).mockRejectedValue( + new Error('backend unavailable'), + ); + const tool = + kind === 'write' + ? new WriteFileTool(config).build({ + file_path: file, + content: 'created', + }) + : new EditTool(config).build({ + file_path: file, + old_string: '', + new_string: 'created', + }); + const result = await tool.execute(new AbortController().signal); + expect(result.error).toBeDefined(); + expect(writeSandboxFile).toHaveBeenCalledTimes(1); + expect(vi.mocked(writeSandboxFile).mock.calls[0][1].expected).toBeNull(); + expect(write).not.toHaveBeenCalled(); + expect(fs.existsSync(path.dirname(file))).toBe(false); + }, + ); + + it.each(['write', 'edit'] as const)( + 'keeps the pre-read version across %s preparation', + async (kind) => { + const file = path.join(root, 'file'); + fs.writeFileSync(file, 'original'); + const expected = getSandboxFileVersion(file); + vi.spyOn(config, 'getFileReadCacheDisabled').mockReturnValue(true); + const read = service.readTextFile.bind(service); + vi.spyOn(service, 'readTextFile').mockImplementation(async (params) => { + const text = await read(params); + fs.writeFileSync(file, 'external content'); + return text; + }); + vi.mocked(writeSandboxFile).mockRejectedValue( + Object.assign(new Error('stale'), { code: 'ESTALE' }), + ); + const tool = + kind === 'write' + ? new WriteFileTool(config).build({ file_path: file, content: 'new' }) + : new EditTool(config).build({ + file_path: file, + old_string: 'original', + new_string: 'new', + }); + const result = await tool.execute(new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.FILE_CHANGED_SINCE_READ); + expect(vi.mocked(writeSandboxFile).mock.calls[0][1].expected).toEqual( + expected, + ); + expect(fs.readFileSync(file, 'utf8')).toBe('external content'); + }, + ); +}); diff --git a/packages/core/src/sandbox/runtime-file.ts b/packages/core/src/sandbox/runtime-file.ts new file mode 100644 index 00000000000..ffb4953e10f --- /dev/null +++ b/packages/core/src/sandbox/runtime-file.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { + encodeTextFileContentAsync, + type CoreWriteTextFileRequest, +} from '../services/fileSystemService.js'; +import { + getSandboxFileVersion, + type SandboxFileVersion, +} from './file-version.js'; +import { writeSandboxFile } from './file-worker-client.js'; +import { assertShellSandboxCwd } from './runtime-shell-policy.js'; + +export function captureRuntimeFileVersion( + config: Config, + filePath: string, +): SandboxFileVersion | null | undefined { + if (!config.getShellExecutionSandbox?.()) return undefined; + return getSandboxFileVersion(filePath); +} + +export async function writeRuntimeFile( + config: Config, + params: CoreWriteTextFileRequest, + expected: SandboxFileVersion | null | undefined, + signal: AbortSignal, +): Promise { + const policy = config.getShellExecutionSandbox?.(); + if (!policy) { + await config.getFileSystemService().writeTextFile(params); + return; + } + signal.throwIfAborted(); + if (expected === undefined) throw new Error('Missing sandbox file version.'); + assertShellSandboxCwd(policy, config.getTargetDir()); + const content = await encodeTextFileContentAsync( + params.path, + params.content, + params._meta, + ); + await writeSandboxFile( + policy, + { + operation: 'write', + destination: params.path, + content, + expected, + }, + signal, + ); +} diff --git a/packages/core/src/sandbox/runtime-shell-policy.test.ts b/packages/core/src/sandbox/runtime-shell-policy.test.ts new file mode 100644 index 00000000000..c9f6d739c75 --- /dev/null +++ b/packages/core/src/sandbox/runtime-shell-policy.test.ts @@ -0,0 +1,333 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConfigParameters } from '../config/config.js'; +import { + admitShellSandbox, + assertShellSandboxCwd, + probeShellSandbox, +} from './runtime-shell-policy.js'; + +const executeBwrap = vi.hoisted(() => vi.fn()); +vi.mock('./bwrap-execution.js', () => ({ executeBwrap })); + +describe('runtime shell policy admission', () => { + let root: string; + let params: ConfigParameters; + beforeEach(() => { + vi.clearAllMocks(); + for (const name of [ + 'SANDBOX', + 'QWEN_SANDBOX', + 'QWEN_SANDBOX_NET', + 'QWEN_SANDBOX_PROXY_COMMAND', + ]) + vi.stubEnv(name, undefined); + root = realpathSync( + mkdtempSync(path.join(os.tmpdir(), 'qwen-policy-test-')), + ); + for (const name of [ + 'workspace', + 'outside', + 'installation', + 'state', + 'runtime', + 'config', + ]) + mkdirSync(path.join(root, name)); + params = { + targetDir: path.join(root, 'workspace'), + cwd: path.join(root, 'workspace'), + debugMode: false, + bareMode: true, + interactive: false, + shellExecutionSandbox: { + workspace: path.join(root, 'workspace'), + installation: path.join(root, 'installation'), + state: path.join(root, 'state'), + filesystem: 'workspace-write', + network: 'closed', + }, + } as ConfigParameters; + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + rmSync(root, { recursive: true, force: true }); + }); + const admit = (params: ConfigParameters, root: string) => + admitShellSandbox( + params, + path.join(root, 'runtime'), + path.join(root, 'config'), + ); + + it('snapshots the canonical policy and both host state roots', () => { + const workspaceAlias = path.join(root, 'workspace-alias'); + const installationAlias = path.join(root, 'installation-alias'); + const stateAlias = path.join(root, 'state-alias'); + for (const [target, alias] of [ + [path.join(root, 'workspace'), workspaceAlias], + [path.join(root, 'installation'), installationAlias], + [path.join(root, 'state'), stateAlias], + ]) + symlinkSync( + target, + alias, + process.platform === 'win32' ? 'junction' : 'dir', + ); + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox!, + workspace: workspaceAlias, + installation: installationAlias, + state: stateAlias, + bwrapPath: path.join(root, 'installation', 'bwrap'), + }; + const policy = admit(params, root)!; + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox!, + workspace: path.join(root, 'outside'), + network: 'open', + }; + expect(policy.workspace).toBe(path.join(root, 'workspace')); + expect(policy.installation).toBe(path.join(root, 'installation')); + expect(policy.state).toBe(path.join(root, 'state')); + expect(policy.bwrapPath).toBe(path.join(root, 'installation', 'bwrap')); + expect(policy.network).toBe('closed'); + expect(Object.isFrozen(policy)).toBe(true); + expect(policy).not.toHaveProperty('protectedRoots'); + }); + + it('canonicalizes workspace-local masks and rejects masks outside it', () => { + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox!, + maskedPaths: [path.join(root, 'workspace', '.qwen', 'review-leases')], + }; + const policy = admit(params, root)!; + expect(policy.maskedPaths).toEqual([ + path.join(root, 'workspace', '.qwen', 'review-leases'), + ]); + expect(Object.isFrozen(policy.maskedPaths)).toBe(true); + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox, + maskedPaths: [path.join(root, 'outside')], + }; + expect(() => admit(params, root)).toThrow('inside the workspace'); + }); + + it('keeps frontend mode outside core policy admission', () => { + expect( + admit({ ...params, bareMode: false, interactive: true }, root), + ).toBeDefined(); + }); + + it('preserves whole-CLI bwrap when the new policy is absent', () => { + expect( + admit( + { + ...params, + shellExecutionSandbox: undefined, + sandbox: { command: 'bwrap' }, + }, + root, + ), + ).toBeUndefined(); + }); + + it.each([ + 'SANDBOX', + 'QWEN_SANDBOX', + 'QWEN_SANDBOX_NET', + 'QWEN_SANDBOX_PROXY_COMMAND', + ])('rejects legacy %s environment', (name) => { + vi.stubEnv(name, 'bwrap'); + expect(() => admit(params, root)).toThrow('legacy sandbox environment'); + }); + + it.each(['SANDBOX', 'QWEN_SANDBOX_NET', 'QWEN_SANDBOX_PROXY_COMMAND'])( + 'ignores empty legacy %s environment', + (name) => { + vi.stubEnv(name, ' '); + expect(() => admit(params, root)).not.toThrow(); + }, + ); + + it.each(['false', '0'])('accepts disabled QWEN_SANDBOX=%s', (value) => { + vi.stubEnv('QWEN_SANDBOX', value); + expect(() => admit(params, root)).not.toThrow(); + }); + + it('rejects a distinct cwd outside the admitted workspace', () => { + expect(() => + admit({ ...params, cwd: path.join(root, 'outside') }, root), + ).toThrow('admitted workspace'); + }); + + it('rejects a missing cwd with the policy error', () => { + const policy = admit(params, root)!; + expect(() => + assertShellSandboxCwd(policy, path.join(root, 'workspace', 'missing')), + ).toThrow('admitted workspace'); + }); + + it('rejects cwd symlinks into another workspace', () => { + symlinkSync( + path.join(root, 'outside'), + path.join(root, 'workspace', 'link'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + const policy = admit(params, root)!; + expect(() => + assertShellSandboxCwd(policy, path.join(root, 'workspace', 'link')), + ).toThrow('admitted workspace'); + expect(() => + assertShellSandboxCwd(policy, path.join(root, 'workspace')), + ).not.toThrow(); + }); + + it.each(['runtime', 'config', 'state', 'installation'])( + 'rejects workspace overlap with %s', + (name) => { + params.targetDir = path.join(root, name); + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox!, + workspace: params.targetDir, + }; + expect(() => admit(params, root)).toThrow('overlaps protected'); + }, + ); + + it('rejects workspace ancestors and descendants of protected roots', () => { + params.targetDir = root; + params.cwd = root; + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox!, + workspace: root, + }; + expect(() => admit(params, root)).toThrow('overlaps protected'); + + const nested = path.join(root, 'runtime', 'nested'); + mkdirSync(nested); + params.targetDir = nested; + params.cwd = nested; + params.shellExecutionSandbox = { + ...params.shellExecutionSandbox, + workspace: nested, + }; + expect(() => admit(params, root)).toThrow('overlaps protected'); + }); + + it.each([[['']], [['none']], [[' NONE ']]])( + 'accepts the no-extension override %j', + (overrideExtensions) => { + expect(() => + admit({ ...params, overrideExtensions }, root), + ).not.toThrow(); + }, + ); + + it.each>([ + { provisionalWorkspace: true }, + { sdkMode: true }, + { experimentalZedIntegration: true }, + { overrideExtensions: ['extension'] }, + { mcpServers: { x: { command: 'node' } } }, + { topTierMcpServers: { x: { command: 'node' } } }, + { toolDiscoveryCommand: 'node' }, + { toolCallCommand: 'node' }, + { mcpServerCommand: 'node' }, + { lsp: { enabled: true } }, + { sandbox: { command: 'docker' } }, + { agentExecutionBackend: 'container' }, + { executionEnvironmentFactory: vi.fn() }, + ])('rejects unsupported startup inputs %j', (overrides) => { + expect(() => admit({ ...params, ...overrides }, root)).toThrow( + 'does not support', + ); + }); + it('probes a fixed command and requires a confirmed zero exit receipt', async () => { + vi.stubGlobal( + 'process', + Object.create(process, { platform: { value: 'linux' } }), + ); + executeBwrap.mockResolvedValue({ + result: Promise.resolve({ + sandboxStatus: { state: 'confirmed', exitCode: 0 }, + }), + }); + await probeShellSandbox(admit(params, root)!); + expect(executeBwrap).toHaveBeenCalledWith( + expect.anything(), + { + executable: '/bin/bash', + args: ['-c', 'true'], + cwd: params.targetDir, + env: { PATH: '/usr/bin:/bin' }, + }, + expect.any(Function), + expect.any(AbortSignal), + ); + executeBwrap.mockResolvedValue({ + result: Promise.resolve({ sandboxStatus: { state: 'unconfirmed' } }), + }); + await expect(probeShellSandbox(admit(params, root)!)).rejects.toThrow( + 'capability probe failed', + ); + expect(executeBwrap).toHaveBeenCalledTimes(2); + }); + + it('aborts a hung capability probe after ten seconds', async () => { + vi.stubGlobal( + 'process', + Object.create(process, { platform: { value: 'linux' } }), + ); + const timeout = new AbortController(); + const timeoutSpy = vi + .spyOn(AbortSignal, 'timeout') + .mockReturnValue(timeout.signal); + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + executeBwrap.mockImplementation( + async (_policy, _command, _output, signal: AbortSignal) => { + started(); + return { + result: new Promise((resolve) => + signal.addEventListener( + 'abort', + () => + resolve({ + sandboxStatus: { state: 'unconfirmed' }, + aborted: true, + }), + { once: true }, + ), + ), + }; + }, + ); + try { + const pending = probeShellSandbox(admit(params, root)!); + await ready; + timeout.abort(); + await expect(pending).rejects.toThrow('capability probe failed'); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); + } finally { + timeoutSpy.mockRestore(); + } + }); +}); diff --git a/packages/core/src/sandbox/runtime-shell-policy.ts b/packages/core/src/sandbox/runtime-shell-policy.ts new file mode 100644 index 00000000000..1b073328684 --- /dev/null +++ b/packages/core/src/sandbox/runtime-shell-policy.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { realpathSync, statSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { ConfigParameters } from '../config/config.js'; +import { isSubpath, realpathNearestExisting } from '../utils/paths.js'; +import type { BwrapPolicy } from './bwrap-execution.js'; + +export function assertShellSandboxCwd( + policy: Readonly, + cwd: string, +): void { + if ( + !path.isAbsolute(cwd) || + statSync(cwd, { throwIfNoEntry: false })?.isDirectory() !== true || + !isSubpath(policy.workspace, realpathSync(cwd)) + ) { + throw new Error( + 'Shell sandbox cwd must remain inside the admitted workspace.', + ); + } +} + +export function admitShellSandbox( + params: ConfigParameters, + runtimeRoot: string, + globalConfigRoot: string, +): Readonly | undefined { + const policy = params.shellExecutionSandbox; + if (!policy) return undefined; + if (params.sandbox?.command === 'bwrap') { + throw new Error( + 'Whole-CLI bwrap is no longer supported. Use tools.executionSandbox instead.', + ); + } + const legacySelection = process.env['QWEN_SANDBOX']?.trim().toLowerCase(); + if ( + process.env['SANDBOX']?.trim() || + (legacySelection && !['false', '0'].includes(legacySelection)) || + process.env['QWEN_SANDBOX_NET']?.trim() || + process.env['QWEN_SANDBOX_PROXY_COMMAND']?.trim() + ) { + throw new Error( + 'Tool execution sandbox cannot be combined with legacy sandbox environment settings. Remove SANDBOX, QWEN_SANDBOX, QWEN_SANDBOX_NET and QWEN_SANDBOX_PROXY_COMMAND and restart.', + ); + } + if ( + params.provisionalWorkspace || + params.sdkMode || + params.experimentalZedIntegration || + params.sandbox || + params.overrideExtensions?.some( + (name) => name.trim() !== '' && name.trim().toLowerCase() !== 'none', + ) || + Object.keys(params.mcpServers ?? {}).length || + Object.keys(params.topTierMcpServers ?? {}).length || + params.mcpServerCommand || + params.toolDiscoveryCommand || + params.toolCallCommand || + params.lsp?.enabled || + params.lspClient || + params.agentExecutionBackend !== undefined || + params.executionEnvironmentFactory !== undefined + ) { + throw new Error( + 'Tool execution sandbox does not support SDK/ACP sessions, provisional workspaces, extensions, MCP, discovery, LSP, agent execution environments, or a whole-CLI sandbox.', + ); + } + if ( + !['read-only', 'workspace-write'].includes(policy.filesystem) || + !['open', 'closed'].includes(policy.network) + ) { + throw new Error('Unsupported shell sandbox policy.'); + } + if ( + policy.bwrapPath !== undefined && + (typeof policy.bwrapPath !== 'string' || !path.isAbsolute(policy.bwrapPath)) + ) { + throw new Error('Invalid tool execution sandbox paths.'); + } + const canonical = (value: string) => { + if (typeof value !== 'string' || !path.isAbsolute(value)) + throw new Error('Shell sandbox paths must be absolute.'); + return realpathNearestExisting(value); + }; + const workspace = canonical(policy.workspace); + const installation = canonical(policy.installation); + const state = canonical(policy.state); + const maskedPaths = Object.freeze((policy.maskedPaths ?? []).map(canonical)); + const protectedRoots = Object.freeze( + [installation, state, runtimeRoot, globalConfigRoot].map(canonical), + ); + if ( + protectedRoots.some( + (root) => isSubpath(root, workspace) || isSubpath(workspace, root), + ) + ) { + throw new Error( + 'Shell sandbox workspace overlaps protected state or installation.', + ); + } + if ( + maskedPaths.some( + (maskedPath) => + maskedPath === workspace || !isSubpath(workspace, maskedPath), + ) + ) { + throw new Error('Shell sandbox masks must remain inside the workspace.'); + } + const admitted: Readonly = Object.freeze({ + workspace, + installation, + state, + ...(maskedPaths.length > 0 ? { maskedPaths } : {}), + filesystem: policy.filesystem, + network: policy.network, + ...(policy.bwrapPath ? { bwrapPath: policy.bwrapPath } : {}), + }); + assertShellSandboxCwd(admitted, params.targetDir); + assertShellSandboxCwd(admitted, params.cwd ?? process.cwd()); + return admitted; +} + +export async function probeShellSandbox( + policy: Readonly, + signal: AbortSignal = new AbortController().signal, +): Promise { + signal.throwIfAborted(); + if (process.platform !== 'linux') + throw new Error('bwrap execution requires Linux.'); + if (realpathNearestExisting(policy.state) !== policy.state) + throw new Error('Sandbox state directory changed after admission.'); + await mkdir(policy.state, { recursive: true, mode: 0o700 }); + if (realpathSync(policy.state) !== policy.state) + throw new Error('Sandbox state directory changed after admission.'); + const { executeBwrap } = await import('./bwrap-execution.js'); + const handle = await executeBwrap( + policy, + { + executable: '/bin/bash', + args: ['-c', 'true'], + cwd: policy.workspace, + env: { PATH: '/usr/bin:/bin' }, + }, + () => {}, + AbortSignal.any([signal, AbortSignal.timeout(10_000)]), + ); + const result = await handle.result; + if ( + result.sandboxStatus.state !== 'confirmed' || + result.sandboxStatus.exitCode !== 0 || + result.error || + result.aborted + ) { + throw new Error( + `Sandbox capability probe failed: ${result.error?.message ?? (result.output || result.sandboxStatus.state)}`, + ); + } +} diff --git a/packages/core/src/sandbox/runtime-shell.test.ts b/packages/core/src/sandbox/runtime-shell.test.ts new file mode 100644 index 00000000000..a955d49e1c6 --- /dev/null +++ b/packages/core/src/sandbox/runtime-shell.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { executeRuntimeShell } from './runtime-shell.js'; +import { executeBwrap } from './bwrap-execution.js'; +import { ShellExecutionService } from '../services/shellExecutionService.js'; +import { assertShellSandboxCwd } from './runtime-shell-policy.js'; +import { sessionIdContext } from '../utils/sessionIdContext.js'; + +vi.mock('./bwrap-execution.js', () => ({ executeBwrap: vi.fn() })); +vi.mock('./runtime-shell-policy.js', () => ({ + assertShellSandboxCwd: vi.fn(), +})); +vi.mock('../services/shellExecutionService.js', () => ({ + ShellExecutionService: { execute: vi.fn() }, +})); + +describe('runtime shell dispatch', () => { + const policy = { + workspace: '/workspace', + installation: '/installation', + state: '/state', + filesystem: 'workspace-write' as const, + network: 'closed' as const, + }; + const runtime = (enabled = true, sessionId = 'runtime-session') => + ({ + getShellExecutionSandbox: () => (enabled ? policy : undefined), + getTargetDir: () => '/workspace', + getSessionId: () => sessionId, + storage: { getProjectDir: () => `/state/${sessionId}` }, + }) as unknown as Config; + const callback = vi.fn(); + const signal = new AbortController().signal; + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.unstubAllEnvs()); + + it('preserves the legacy service and all options without policy', async () => { + const options = { streamStdout: true }; + const handle = { pid: 7, result: Promise.resolve({ output: 'legacy' }) }; + vi.mocked(ShellExecutionService.execute).mockResolvedValue( + handle as Awaited>, + ); + expect( + await executeRuntimeShell( + runtime(false), + 'echo ok', + '/workspace', + callback, + signal, + false, + {}, + options, + ), + ).toBe(handle); + expect(ShellExecutionService.execute).toHaveBeenCalledWith( + 'echo ok', + '/workspace', + callback, + signal, + false, + {}, + options, + ); + expect(executeBwrap).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'uses a literal command and owning session environment (pty=%s)', + async (pty) => { + vi.stubEnv('GH_TOKEN', 'expected-user-value'); + const command = 'printf "%s" "$HOME"; echo literal'; + await sessionIdContext.run('different-session', () => + executeRuntimeShell( + runtime(), + command, + '/workspace/sub', + callback, + signal, + pty, + { pager: 'less' }, + { streamStdout: true }, + ), + ); + expect(assertShellSandboxCwd).toHaveBeenCalledWith(policy, '/workspace'); + expect(assertShellSandboxCwd).toHaveBeenCalledWith( + policy, + '/workspace/sub', + ); + const args = vi.mocked(executeBwrap).mock.calls[0]; + expect(args[0]).toBe(policy); + expect(args[1]).toMatchObject({ + executable: '/bin/bash', + args: ['-c', command], + cwd: '/workspace/sub', + env: { + QWEN_CODE: '1', + QWEN_CODE_SESSION_ID: 'runtime-session', + QWEN_CODE_PROJECT_DIR: '/state/runtime-session', + GH_TOKEN: 'expected-user-value', + TERM: 'xterm-256color', + PAGER: 'less', + }, + }); + if (pty) expect(args[1].env['GIT_PAGER']).toBe('less'); + expect(args.slice(2)).toEqual([ + callback, + signal, + pty, + { pager: 'less' }, + { streamStdout: true }, + ]); + expect(ShellExecutionService.execute).not.toHaveBeenCalled(); + }, + ); + + it('does not fall back after admission or backend failure', async () => { + vi.mocked(assertShellSandboxCwd).mockImplementationOnce(() => { + throw new Error('outside'); + }); + await expect( + executeRuntimeShell( + runtime(), + 'touch marker', + '/other', + callback, + signal, + false, + ), + ).rejects.toThrow('outside'); + expect(executeBwrap).not.toHaveBeenCalled(); + vi.mocked(executeBwrap).mockRejectedValueOnce(new Error('setup failed')); + await expect( + executeRuntimeShell( + runtime(), + 'touch marker', + '/workspace', + callback, + signal, + false, + ), + ).rejects.toThrow('setup failed'); + expect(ShellExecutionService.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/sandbox/runtime-shell.ts b/packages/core/src/sandbox/runtime-shell.ts new file mode 100644 index 00000000000..8010613c628 --- /dev/null +++ b/packages/core/src/sandbox/runtime-shell.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { ShellExecutionService } from '../services/shellExecutionService.js'; +import type { + ShellExecutionHandle, + ShellExecutionConfig, + ShellExecuteOptions, + ShellOutputEvent, +} from '../services/shellExecutionService.js'; +import { getShellContextEnvVars } from '../services/shellContextEnv.js'; +import { sanitizeChildEnv } from '../utils/sanitize-child-env.js'; +import { getShellPagerEnv } from '../utils/shell-pager-env.js'; +import { sessionIdContext } from '../utils/sessionIdContext.js'; +import { executeBwrap } from './bwrap-execution.js'; +import { assertShellSandboxCwd } from './runtime-shell-policy.js'; + +export function executeRuntimeShell( + runtime: Config, + command: string, + cwd: string, + onOutput: (event: ShellOutputEvent) => void, + signal: AbortSignal, + usePty: boolean, + config: ShellExecutionConfig = {}, + options: ShellExecuteOptions = {}, +): Promise { + const policy = runtime.getShellExecutionSandbox?.(); + if (!policy) { + return ShellExecutionService.execute( + command, + cwd, + onOutput, + signal, + usePty, + config, + options, + ); + } + try { + assertShellSandboxCwd(policy, runtime.getTargetDir()); + assertShellSandboxCwd(policy, cwd); + const env = Object.fromEntries( + Object.entries({ + ...sanitizeChildEnv(process.env), + QWEN_CODE: '1', + TERM: 'xterm-256color', + ...getShellPagerEnv(config.pager, { + includeGitPager: usePty, + platform: 'linux', + }), + ...sessionIdContext.run(runtime.getSessionId(), getShellContextEnvVars), + QWEN_CODE_SESSION_ID: runtime.getSessionId(), + QWEN_CODE_PROJECT_DIR: runtime.storage.getProjectDir(), + }).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); + return executeBwrap( + policy, + { executable: '/bin/bash', args: ['-c', command], cwd, env }, + onOutput, + signal, + usePty, + config, + options, + ); + } catch (error) { + return Promise.reject(error); + } +} diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index a0312e3a044..c09b918913a 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -3218,6 +3218,38 @@ bad`); vi.restoreAllMocks(); }); + it.each([ + { executor: executorConfig.executor }, + { mcpServers: { remote: { command: 'node' } } }, + { hooks: { Stop: [] } }, + ])( + 'rejects sandbox child overrides %j before invoking any executor', + async (overrides) => { + vi.spyOn(mockConfig, 'getShellExecutionSandbox').mockReturnValue( + {} as NonNullable>, + ); + const externalCreate = vi.fn(); + vi.spyOn(mockConfig, 'getExternalAgentExecutor').mockReturnValue({ + create: externalCreate, + }); + await expect( + manager.createAgentHeadless( + { + ...executorConfig, + executor: undefined, + ...overrides, + } as SubagentConfig, + mockConfig, + ), + ).rejects.toThrow( + 'does not support agent executors, MCP servers or hooks', + ); + expect(externalCreate).not.toHaveBeenCalled(); + expect(mockAgentHeadlessCreate).not.toHaveBeenCalled(); + expect(mockToolRegistry.warmAll).not.toHaveBeenCalled(); + }, + ); + it('refuses to run in-process when no executor is registered', async () => { vi.spyOn(mockConfig, 'getExternalAgentExecutor').mockReturnValue( undefined, diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 37ad633af02..f7961305b56 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -933,6 +933,18 @@ export class SubagentManager { subagentId?: string; }, ): Promise<{ subagent: SubagentExecutor; dispose: () => Promise }> { + if ( + runtimeContext.getShellExecutionSandbox?.() && + (config.executor !== undefined || + Object.keys(config.mcpServers ?? {}).length > 0 || + Object.keys(config.hooks ?? {}).length > 0) + ) { + throw new SubagentError( + 'Tool execution sandbox does not support agent executors, MCP servers or hooks.', + SubagentErrorCode.INVALID_CONFIG, + config.name, + ); + } // Track per-spawn cleanup callbacks declared outside the inner // `try/catch` so the catch can fire them on a constructor failure // before the caller ever receives the return value. The successful diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 7edad373648..9962ce6d9a6 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -863,6 +863,29 @@ describe('AgentTool', () => { ); }); + it.each>([ + { isolation: 'worktree' }, + { working_dir: '/another/workspace' }, + { name: 'teammate' }, + ])( + 'rejects unsupported sandbox agent entry %j before execution', + async (overrides) => { + config.getShellExecutionSandbox = vi.fn().mockReturnValue({}); + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation({ + description: 'Sandbox agent', + prompt: 'inspect the project', + subagent_type: 'file-search', + ...overrides, + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.llmContent).toContain('same-workspace in-process agents'); + expect(mockSubagentManager.loadSubagent).not.toHaveBeenCalled(); + expect(AgentHeadless.create).not.toHaveBeenCalled(); + }, + ); + describe('initialization', () => { it('should initialize with correct name and properties', () => { expect(agentTool.name).toBe('agent'); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index e4185bf43b2..7377ca9c77d 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -596,7 +596,10 @@ export async function rebuildToolRegistryOnOverride( skipDiscovery: true, forSubAgent: true, }); - if (!override.getExecutionEnvironment?.()) { + if ( + !override.getExecutionEnvironment?.() && + !base.getShellExecutionSandbox?.() + ) { agentRegistry.copyDiscoveredToolsFrom(base.getToolRegistry()); } ov.getToolRegistry = () => agentRegistry; @@ -2457,6 +2460,18 @@ class AgentToolInvocation extends BaseToolInvocation { executionBackendError, ); } + if ( + this.config.getShellExecutionSandbox?.() && + (this.params.isolation || this.params.working_dir || this.params.name) + ) { + const message = + 'Tool execution sandbox supports only same-workspace in-process agents; worktrees, working_dir and teammates are unavailable.'; + return { + llmContent: message, + returnDisplay: message, + error: { message }, + }; + } const sessionWorkflowAgent = this.config.getSessionWorkflowPlanRevision?.() !== undefined; if (this.params.plan_mode_required === true) { diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 6bfa5caa94f..b2ef6428607 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -21,6 +21,11 @@ import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; +import { + captureRuntimeFileVersion, + writeRuntimeFile, +} from '../sandbox/runtime-file.js'; +import type { SandboxFileVersion } from '../sandbox/file-version.js'; import { isAnyAutoMemPath, isTeamAutoMemPath } from '../memory/paths.js'; import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js'; import { @@ -115,6 +120,7 @@ export interface EditToolParams { } interface CalculatedEdit { + sandboxFileVersion?: SandboxFileVersion | null; currentContent: string | null; newContent: string; occurrences: number; @@ -145,6 +151,10 @@ class EditToolInvocation implements ToolInvocation { * @throws File system errors if reading the file fails unexpectedly (e.g., permissions) */ private async calculateEdit(params: EditToolParams): Promise { + const sandboxFileVersion = captureRuntimeFileVersion( + this.config, + params.file_path, + ); const replaceAll = params.replace_all ?? false; let currentContent: string | null = null; let fileExists = await isFilefileExists(params.file_path); @@ -374,6 +384,7 @@ class EditToolInvocation implements ToolInvocation { return { currentContent, newContent, + sandboxFileVersion, occurrences, error, isNewFile, @@ -416,7 +427,10 @@ class EditToolInvocation implements ToolInvocation { if (abortSignal.aborted) { throw error; } - const errorMsg = getErrorMessage(error); + const errorMsg = + isNodeError(error) && error.code === 'EISDIR' + ? `Target is a directory, not a file: ${this.params.file_path} (${error.code})` + : getErrorMessage(error); throw new Error(`Error preparing edit: ${errorMsg}`); } @@ -546,14 +560,9 @@ class EditToolInvocation implements ToolInvocation { // // It does NOT eliminate the race. A concurrent writer that // lands between this stat and the writeTextFile call below - // can still be clobbered — that residual is an OS-level - // limitation of the stat-then-write pattern, and the only - // way to close it is an atomic write (write to a temp file, - // then rename) or a content-hash post-check that re-reads - // the bytes after the write. Both are deferred to a follow-up - // PR; operators who care about strict overwrite-protection - // should set `fileReadCacheDisabled: true` and rely on - // application-level locking. + // can still be clobbered. Atomic replacement is not compare-and-swap; + // the sandbox worker rechecks the prepared version before commit, + // but strict protection against concurrent writers requires locking. // // Run unconditionally (not gated on `editData.isNewFile`): // `isNewFile` was decided back in calculateEdit, but a file @@ -596,7 +605,9 @@ class EditToolInvocation implements ToolInvocation { // directories on the failure path — a real (if minor) FS // litter that the previous order created on every rejected // edit. - this.ensureParentDirectoriesExist(this.params.file_path); + if (!this.config.getShellExecutionSandbox?.()) { + this.ensureParentDirectoriesExist(this.params.file_path); + } // For new files, apply default file encoding setting // For existing files, preserve the original encoding (BOM and charset) @@ -609,25 +620,35 @@ class EditToolInvocation implements ToolInvocation { // No explicit setting: auto-detect (e.g. .ps1 on non-UTF-8 Windows) useBOM = needsUtf8Bom(this.params.file_path); } - await this.config.getFileSystemService().writeTextFile({ - path: this.params.file_path, - content: editData.newContent, - toolWriteOrigin: 'edit', - _meta: { - bom: useBOM, + await writeRuntimeFile( + this.config, + { + path: this.params.file_path, + content: editData.newContent, + toolWriteOrigin: 'edit', + _meta: { + bom: useBOM, + }, }, - }); + editData.sandboxFileVersion, + signal, + ); } else { - await this.config.getFileSystemService().writeTextFile({ - path: this.params.file_path, - content: editData.newContent, - toolWriteOrigin: 'edit', - _meta: { - bom: editData.bom, - encoding: editData.encoding, - lineEnding: editData.lineEnding, + await writeRuntimeFile( + this.config, + { + path: this.params.file_path, + content: editData.newContent, + toolWriteOrigin: 'edit', + _meta: { + bom: editData.bom, + encoding: editData.encoding, + lineEnding: editData.lineEnding, + }, }, - }); + editData.sandboxFileVersion, + signal, + ); } // Track AI contribution for commit attribution @@ -732,7 +753,14 @@ class EditToolInvocation implements ToolInvocation { returnDisplay: `Error writing file: ${errorMsg}`, error: { message: errorMsg, - type: ToolErrorType.FILE_WRITE_FAILURE, + type: + isNodeError(error) && + error.code === 'ESTALE' && + this.config.getShellExecutionSandbox?.() + ? ToolErrorType.FILE_CHANGED_SINCE_READ + : isNodeError(error) && error.code === 'EISDIR' + ? ToolErrorType.TARGET_IS_DIRECTORY + : ToolErrorType.FILE_WRITE_FAILURE, }, }; } diff --git a/packages/core/src/tools/monitor.test.ts b/packages/core/src/tools/monitor.test.ts index 73aa05c49dc..8b2684e1881 100644 --- a/packages/core/src/tools/monitor.test.ts +++ b/packages/core/src/tools/monitor.test.ts @@ -27,6 +27,10 @@ vi.mock('node:os', async (importOriginal) => { // Mock child_process.spawn const mockSpawn = vi.hoisted(() => vi.fn()); +const mockRuntimeShell = vi.hoisted(() => vi.fn()); +vi.mock('../sandbox/runtime-shell.js', () => ({ + executeRuntimeShell: mockRuntimeShell, +})); vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -140,6 +144,10 @@ vi.mock('../utils/shellAstParser.js', () => ({ import { MonitorTool, sanitizeMonitorLine } from './monitor.js'; import type { Config } from '../config/config.js'; +import type { + ShellExecutionResult, + ShellOutputEvent, +} from '../services/shellExecutionService.js'; import { MonitorRegistry } from '../services/monitorRegistry.js'; import type { ToolCallConfirmationDetails } from './tools.js'; import { runWithAgentContext } from '../agents/runtime/agent-context.js'; @@ -201,6 +209,7 @@ describe('MonitorTool', () => { delete process.env['GIT_PAGER']; vi.clearAllMocks(); + mockRuntimeShell.mockReset(); mockOsPlatform.mockReturnValue('linux'); monitorRegistry = new MonitorRegistry(); @@ -213,6 +222,7 @@ describe('MonitorTool', () => { mockConfig = { getTargetDir: vi.fn().mockReturnValue('/test/dir'), + getShellExecutionSandbox: vi.fn().mockReturnValue(undefined), getMonitorRegistry: vi.fn().mockReturnValue(monitorRegistry), getPermissionManager: vi.fn().mockReturnValue(undefined), getWorkspaceContext: vi.fn().mockReturnValue({ @@ -275,6 +285,127 @@ describe('MonitorTool', () => { } ).createInvocation(params); + describe('tool execution sandbox', () => { + beforeEach(() => { + vi.mocked(mockConfig.getShellExecutionSandbox).mockReturnValue( + {} as NonNullable>, + ); + }); + + it('streams stdout and stderr independently and settles the existing registry', async () => { + let output!: (event: ShellOutputEvent) => void; + let signal!: AbortSignal; + let finish!: (result: ShellExecutionResult) => void; + const result = new Promise((resolve) => { + finish = resolve; + }); + mockRuntimeShell.mockImplementation( + async (_config, _command, _cwd, onOutput, abortSignal) => { + output = onOutput; + signal = abortSignal; + return { pid: 9876, result }; + }, + ); + const emit = vi.spyOn(monitorRegistry, 'emitEvent'); + const turn = new AbortController(); + await createInvocation({ command: 'watch command' }).execute(turn.signal); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockRuntimeShell).toHaveBeenCalledWith( + mockConfig, + 'watch command', + '/test/dir', + expect.any(Function), + expect.any(AbortSignal), + false, + { maxBufferedOutputBytes: 4096 }, + { streamStdout: true }, + ); + const entry = monitorRegistry.getRunning()[0]!; + expect(entry.pid).toBe(9876); + turn.abort(); + expect(signal.aborted).toBe(false); + output({ type: 'data', chunk: 'out', stream: 'stdout' }); + output({ type: 'data', chunk: 'err\n', stream: 'stderr' }); + output({ type: 'data', chunk: 'put\nlast', stream: 'stdout' }); + finish({ exitCode: 0, signal: null } as ShellExecutionResult); + await result; + await Promise.resolve(); + expect(emit.mock.calls.map((call) => call[1])).toEqual([ + 'err', + 'output', + 'last', + ]); + expect(entry.status).toBe('completed'); + }); + + it('fails and stops a sandboxed monitor after binary output', async () => { + let output!: (event: ShellOutputEvent) => void; + let signal!: AbortSignal; + mockRuntimeShell.mockImplementation( + async (_config, _command, _cwd, onOutput, abortSignal) => { + output = onOutput; + signal = abortSignal; + return { result: new Promise(() => {}) }; + }, + ); + await createInvocation({ command: 'watch command' }).execute( + new AbortController().signal, + ); + const entry = monitorRegistry.getRunning()[0]!; + output({ type: 'binary_detected' }); + expect(entry.status).toBe('failed'); + expect(signal.aborted).toBe(true); + }); + + it('fails an unconfirmed sandbox completion', async () => { + mockRuntimeShell.mockResolvedValue({ + result: Promise.resolve({ + error: new Error('Sandbox termination is unconfirmed'), + exitCode: 0, + signal: null, + }), + }); + await createInvocation({ command: 'watch command' }).execute( + new AbortController().signal, + ); + await Promise.resolve(); + expect(monitorRegistry.getAll()[0]?.status).toBe('failed'); + }); + + it('stops through the monitor abort controller without replaying on the host', async () => { + let signal!: AbortSignal; + mockRuntimeShell.mockImplementation( + async (_config, _command, _cwd, _output, abortSignal) => { + signal = abortSignal; + return { result: new Promise(() => {}) }; + }, + ); + await createInvocation({ command: 'watch command' }).execute( + new AbortController().signal, + ); + monitorRegistry.cancel(monitorRegistry.getRunning()[0]!.monitorId); + expect(signal.aborted).toBe(true); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('fails setup without falling back to a host process', async () => { + mockRuntimeShell.mockRejectedValue(new Error('sandbox unavailable')); + const result = await createInvocation({ + command: 'watch command', + }).execute(new AbortController().signal); + expect(result.llmContent).toContain('sandbox unavailable'); + expect(monitorRegistry.getRunning()).toEqual([]); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('does not run host AST permission probes', async () => { + const invocation = createInvocation({ command: 'git status' }); + expect(await invocation.getDefaultPermission()).toBe('ask'); + await invocation.getConfirmationDetails(new AbortController().signal); + expect(mockIsShellCommandReadOnlyAST).not.toHaveBeenCalled(); + }); + }); + describe('schema', () => { it('declares monitor limits as integers', () => { const schema = monitorTool.schema.parametersJsonSchema as { diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index 96662fcdcb3..9c485c81244 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -16,7 +16,8 @@ * Auto-stops after max_events or idle_timeout_ms of silence. */ -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { executeRuntimeShell } from '../sandbox/runtime-shell.js'; import os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -171,6 +172,7 @@ class MonitorToolInvocation extends BaseToolInvocation< } override async getDefaultPermission(): Promise { + if (this.config.getShellExecutionSandbox?.()) return 'ask'; const normalized = normalizeMonitorShellCommand(this.params.command); const command = normalized.safetyCommand; const cwd = @@ -222,7 +224,8 @@ class MonitorToolInvocation extends BaseToolInvocation< // permission boundary. let isReadOnly = false; try { - isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd); + if (!this.config.getShellExecutionSandbox?.()) + isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd); } catch (e) { // Conservative fallback: if AST analysis fails, keep the sub-command // in the confirmation scope instead of accidentally dropping it. @@ -365,23 +368,25 @@ class MonitorToolInvocation extends BaseToolInvocation< // Spawn the process const { executable, argsPrefix } = getShellConfiguration(); - let child; + const sandboxed = Boolean(this.config.getShellExecutionSandbox?.()); + let child: ChildProcess | undefined; try { - child = spawn(executable, [...argsPrefix, command], { - cwd: this.params.directory || this.config.getTargetDir(), - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, - env: { - ...sanitizeChildEnv(process.env), - QWEN_CODE: '1', - TERM: 'dumb', // no color codes for streaming - ...getShellPagerEnv(this.config.getShellExecutionConfig().pager, { - includeGitPager: false, - platform: os.platform(), - }), - ...getShellContextEnvVars(), - }, - }); + if (!sandboxed) + child = spawn(executable, [...argsPrefix, command], { + cwd: this.params.directory || this.config.getTargetDir(), + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + env: { + ...sanitizeChildEnv(process.env), + QWEN_CODE: '1', + TERM: 'dumb', // no color codes for streaming + ...getShellPagerEnv(this.config.getShellExecutionConfig().pager, { + includeGitPager: false, + platform: os.platform(), + }), + ...getShellContextEnvVars(), + }, + }); } catch (err) { return { llmContent: `Monitor failed to start: ${getErrorMessage(err)}`, @@ -389,7 +394,7 @@ class MonitorToolInvocation extends BaseToolInvocation< }; } - registration.pid = child.pid; + registration.pid = child?.pid; let exited = false; // Capture async spawn errors (ENOENT, EACCES, etc.) during the window @@ -398,7 +403,7 @@ class MonitorToolInvocation extends BaseToolInvocation< const captureEarlySpawnError = (err: Error): void => { earlySpawnError ??= err; }; - child.on('error', captureEarlySpawnError); + child?.on('error', captureEarlySpawnError); // ----- Line buffering & throttling state --------------------------------- // Declared up-front (before `abortHandler`) so that the synchronous abort @@ -463,34 +468,33 @@ class MonitorToolInvocation extends BaseToolInvocation< }; const killChildProcessGroup = (): void => { - if (exited || !child.pid) return; + const pid = child?.pid; + if (exited || !pid) return; if (process.platform === 'win32') { - const tk = spawn( - 'taskkill', - ['/pid', child.pid.toString(), '/f', '/t'], - { stdio: 'ignore' }, - ); + const tk = spawn('taskkill', ['/pid', pid.toString(), '/f', '/t'], { + stdio: 'ignore', + }); tk.on('error', (err) => debugLogger.warn( - `Monitor taskkill failed for pid ${child.pid}: ${getErrorMessage(err)}`, + `Monitor taskkill failed for pid ${pid}: ${getErrorMessage(err)}`, ), ); } else { try { - process.kill(-child.pid, 'SIGTERM'); + process.kill(-pid, 'SIGTERM'); } catch (err) { debugLogger.warn( - `Monitor ${monitorId} SIGTERM failed (pid=${child.pid}): ${getErrorMessage(err)}`, + `Monitor ${monitorId} SIGTERM failed (pid=${pid}): ${getErrorMessage(err)}`, ); } setTimeout(() => { - if (!exited && child.pid) { + if (!exited) { try { - process.kill(-child.pid, 'SIGKILL'); + process.kill(-pid, 'SIGKILL'); } catch (err) { debugLogger.warn( - `Monitor ${monitorId} SIGKILL escalation failed (pid=${child.pid}): ${getErrorMessage(err)}`, + `Monitor ${monitorId} SIGKILL escalation failed (pid=${pid}): ${getErrorMessage(err)}`, ); } } @@ -519,23 +523,28 @@ class MonitorToolInvocation extends BaseToolInvocation< abortHandler(); entryAc.signal.removeEventListener('abort', abortHandler); ( - child.stdout as { destroy?: () => void } | null | undefined + child?.stdout as { destroy?: () => void } | null | undefined )?.destroy?.(); ( - child.stderr as { destroy?: () => void } | null | undefined + child?.stderr as { destroy?: () => void } | null | undefined )?.destroy?.(); - child.removeListener('error', captureEarlySpawnError); - child.on('error', () => {}); + child?.removeListener('error', captureEarlySpawnError); + child?.on('error', () => {}); return { llmContent: `Monitor failed to start: ${getErrorMessage(err)}`, returnDisplay: `Monitor failed: ${getErrorMessage(err)}`, }; } - const processLines = (buffer: { value: string }, data: Buffer): void => { + const processLines = ( + buffer: { value: string }, + data: Buffer | string, + ): void => { if (registration.status !== 'running') return; - const text = stripAnsi(data.toString('utf-8')); + const text = stripAnsi( + typeof data === 'string' ? data : data.toString('utf-8'), + ); buffer.value += text; // Guard against unbounded partial-line accumulation. If a command emits @@ -570,8 +579,8 @@ class MonitorToolInvocation extends BaseToolInvocation< } }; - child.stdout?.on('data', (data: Buffer) => processLines(stdoutBuf, data)); - child.stderr?.on('data', (data: Buffer) => processLines(stderrBuf, data)); + child?.stdout?.on('data', (data: Buffer) => processLines(stdoutBuf, data)); + child?.stderr?.on('data', (data: Buffer) => processLines(stderrBuf, data)); // Shared cleanup: flush buffers, remove abort listener, log dropped lines. // Called from `close` after stdio streams drain, and from `error` when no @@ -604,7 +613,7 @@ class MonitorToolInvocation extends BaseToolInvocation< const settleFromExit = ( code: number | null, - sig: NodeJS.Signals | null, + sig: NodeJS.Signals | number | null, ): void => { if (registration.status !== 'running') return; // already settled @@ -640,10 +649,70 @@ class MonitorToolInvocation extends BaseToolInvocation< } }; - child.on('exit', onExit); - child.on('close', onClose); - child.on('error', onError); - child.removeListener('error', captureEarlySpawnError); + child?.on('exit', onExit); + child?.on('close', onClose); + child?.on('error', onError); + child?.removeListener('error', captureEarlySpawnError); + + if (sandboxed) { + try { + const shellExecutionConfig = this.config.getShellExecutionConfig(); + const handle = await executeRuntimeShell( + this.config, + command, + this.params.directory || this.config.getTargetDir(), + (event) => { + if (event.type === 'binary_detected') { + if (registration.status === 'running') { + registry.fail(monitorId, 'Binary output detected'); + entryAc.abort(); + } + } else if ( + event.type === 'data' && + typeof event.chunk === 'string' + ) { + processLines( + event.stream === 'stderr' ? stderrBuf : stdoutBuf, + event.chunk, + ); + } + }, + entryAc.signal, + false, + { + ...shellExecutionConfig, + maxBufferedOutputBytes: Math.min( + shellExecutionConfig.maxBufferedOutputBytes ?? + PARTIAL_LINE_BUFFER_CAP, + PARTIAL_LINE_BUFFER_CAP, + ), + }, + { streamStdout: true }, + ); + registration.pid = handle.pid; + void handle.result.then( + (result) => { + exited = true; + cleanup(); + if (result.error) { + if (registration.status === 'running') + registry.fail(monitorId, result.error.message); + } else { + settleFromExit(result.exitCode, result.signal); + } + }, + (error: unknown) => + onError(error instanceof Error ? error : new Error(String(error))), + ); + } catch (error) { + onError(error instanceof Error ? error : new Error(String(error))); + return { + llmContent: `Monitor failed to start: ${getErrorMessage(error)}`, + returnDisplay: `Monitor failed: ${getErrorMessage(error)}`, + error: { message: getErrorMessage(error) }, + }; + } + } if (earlySpawnError) { onError(earlySpawnError); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 5b7e64f8e22..e8e7918dba9 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -15,6 +15,13 @@ import { } from 'vitest'; const mockShellExecutionService = vi.hoisted(() => vi.fn()); +const mockExecuteBwrap = vi.hoisted(() => vi.fn()); +vi.mock('../sandbox/bwrap-execution.js', () => ({ + executeBwrap: mockExecuteBwrap, +})); +vi.mock('../sandbox/runtime-shell-policy.js', () => ({ + assertShellSandboxCwd: vi.fn(), +})); const mockExecFile = vi.hoisted(() => vi.fn()); const mockDebugLogger = vi.hoisted(() => ({ debug: vi.fn(), @@ -288,6 +295,124 @@ describe('ShellTool', () => { ); }); + describe('internal runtime sandbox routing', () => { + beforeEach(() => { + mockConfig.getShellExecutionSandbox = vi.fn().mockReturnValue({ + workspace: '/test/dir', + installation: '/install', + state: '/state', + filesystem: 'workspace-write', + network: 'closed', + }); + mockExecuteBwrap.mockResolvedValue({ + pid: 12345, + result: Promise.resolve({ + rawOutput: Buffer.alloc(0), + output: 'confined', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + sandboxStatus: { state: 'confirmed', exitCode: 0 }, + }), + }); + }); + + it('runs sed through the backend without host preview or write', async () => { + const invocation = shellTool.build({ + command: "sed -i 's/old/new/' file.txt", + is_background: false, + }); + expect( + (await invocation.getConfirmationDetails(new AbortController().signal)) + .type, + ).toBe('exec'); + await invocation.execute(new AbortController().signal); + expect(mockExecuteBwrap).toHaveBeenCalledOnce(); + expect(mockFileSystemService.readTextFile).not.toHaveBeenCalled(); + expect(mockFileSystemService.writeTextFile).not.toHaveBeenCalled(); + expect(mockShellExecutionService).not.toHaveBeenCalled(); + }); + + it('keeps Git/PR metadata subprocesses off the host', async () => { + const gitSpy = vi.spyOn( + await import('node:child_process'), + 'execFileSync', + ); + try { + await shellTool + .build({ command: 'git commit -m test', is_background: false }) + .execute(new AbortController().signal); + await shellTool + .build({ + command: 'gh pr create --title test --body test', + is_background: false, + }) + .execute(new AbortController().signal); + await shellTool + .build({ + command: 'gh pr create --title background --body background', + is_background: true, + }) + .execute(new AbortController().signal); + expect(mockExecuteBwrap).toHaveBeenCalledTimes(3); + expect(mockExecuteBwrap.mock.calls[0][1].args).toEqual([ + '-c', + 'git commit -m test', + ]); + expect(mockExecuteBwrap.mock.calls[1][1].args).toEqual([ + '-c', + 'gh pr create --title test --body test', + ]); + expect(mockExecuteBwrap.mock.calls[2][1].args).toEqual([ + '-c', + 'gh pr create --title background --body background', + ]); + expect(gitSpy).not.toHaveBeenCalled(); + expect(mockExecFile).not.toHaveBeenCalled(); + expect(fetchCurrentBranchPullRequest).not.toHaveBeenCalled(); + } finally { + gitSpy.mockRestore(); + } + }); + + it('routes background execution and closes its stream on setup failure', async () => { + const destroy = vi.fn(); + vi.mocked(fs.createWriteStream).mockReturnValue({ + on: vi.fn(), + destroy, + } as unknown as fs.WriteStream); + mockExecuteBwrap.mockRejectedValueOnce(new Error('sandbox setup failed')); + await expect( + shellTool + .build({ command: 'echo test', is_background: true }) + .execute(new AbortController().signal), + ).rejects.toThrow('sandbox setup failed'); + expect(mockExecuteBwrap.mock.calls[0][4]).toBe(false); + expect(destroy).toHaveBeenCalledOnce(); + expect(fs.rmSync).toHaveBeenCalledWith( + expect.stringMatching(/\.output$/), + { + force: true, + }, + ); + expect( + mockConfig.getBackgroundShellRegistry().register, + ).not.toHaveBeenCalled(); + expect(mockShellExecutionService).not.toHaveBeenCalled(); + }); + + it('uses a conservative permission default without host git probes', async () => { + expect( + await shellTool + .build({ command: 'git status', is_background: false }) + .getDefaultPermission(), + ).toBe('ask'); + }); + }); + describe('gh pr create binding', () => { const fetchCurrentBranchPullRequestMock = vi.mocked( fetchCurrentBranchPullRequest, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 6a3e3d78012..6cd7fb974dc 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -10,6 +10,7 @@ import os from 'node:os'; import crypto from 'node:crypto'; import * as childProcess from 'node:child_process'; import { ApprovalMode, type Config } from '../config/config.js'; +import { executeRuntimeShell } from '../sandbox/runtime-shell.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { ToolErrorType } from './tool-error.js'; import type { @@ -1703,6 +1704,7 @@ export class ShellToolInvocation extends BaseToolInvocation< private getSedEditInfo(): SedEditInfo | null { if ( + this.config.getShellExecutionSandbox?.() || this.params.is_background || LEADING_ENV_ASSIGNMENT_RE.test(this.params.command) ) { @@ -2052,6 +2054,7 @@ export class ShellToolInvocation extends BaseToolInvocation< * - All other commands → 'ask' */ override async getDefaultPermission(): Promise { + if (this.config.getShellExecutionSandbox?.()) return 'ask'; // Gate on the RAW command before `stripShellWrapper` runs. // `stripShellWrapper` drops leading env-assignment tokens AND // unwraps `bash -c '...'` to its inner script — so for @@ -2142,6 +2145,10 @@ export class ShellToolInvocation extends BaseToolInvocation< const subCommands = splitCommands(command); const confirmableSubCommands: string[] = []; for (const sub of subCommands) { + if (this.config.getShellExecutionSandbox?.()) { + confirmableSubCommands.push(sub); + continue; + } let isReadOnly = false; try { isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd); @@ -2313,9 +2320,11 @@ export class ShellToolInvocation extends BaseToolInvocation< // are preserved through to execution; the rewriters operate at the // top-level shell layer and become no-ops when the commit hides // inside a wrapper. - const processedCommand = this.addAttributionToPR( - this.addCoAuthorToGitCommit(this.params.command.trim()), - ); + const processedCommand = this.config.getShellExecutionSandbox?.() + ? this.params.command.trim() + : this.addAttributionToPR( + this.addCoAuthorToGitCommit(this.params.command.trim()), + ); const commandToExecute = processedCommand; const cwd = this.params.directory || this.config.getTargetDir(); @@ -2343,9 +2352,10 @@ export class ShellToolInvocation extends BaseToolInvocation< // `git -C /other commit`), no consumer reads preHead and the // ~10–50 ms execFileSync is dead work that just blocks the // event loop before the user's real command spawns. - const preHead: string | null = commitCtx.attributableInCwd - ? this.getGitHeadSync(cwd) - : null; + const preHead: string | null = + !this.config.getShellExecutionSandbox?.() && commitCtx.attributableInCwd + ? this.getGitHeadSync(cwd) + : null; // Snapshot the attribution inputs BEFORE spawn so bindGhPrCreate can // tell a run that CREATED a PR from one that merely RESOLVED the @@ -2360,7 +2370,10 @@ export class ShellToolInvocation extends BaseToolInvocation< let preRunBranch: string | undefined; let preRunRepoKeys: AttributionRepoKeys | undefined; let preRunGhEnv: Readonly> | undefined; - if (commandRunsGhPrCreate(commandToExecute)) { + if ( + !this.config.getShellExecutionSandbox?.() && + commandRunsGhPrCreate(commandToExecute) + ) { // The verification legs must authenticate the way the create itself // does (inline GH_TOKEN with no ambient gh auth), or the gate's // advertised token shape binds nothing. The inline record is an @@ -2618,7 +2631,8 @@ export class ShellToolInvocation extends BaseToolInvocation< let executionHandle; try { - executionHandle = await ShellExecutionService.execute( + executionHandle = await executeRuntimeShell( + this.config, commandToExecute, cwd, onShellOutputEvent, @@ -2855,7 +2869,10 @@ export class ShellToolInvocation extends BaseToolInvocation< // succeeded but the per-file git note didn't land — without it, the only // signal is a QWEN_DEBUG_LOG_FILE entry the user has likely never set up. let attributionWarning: string | null = null; - if (commitCtx.attributableInCwd) { + if ( + !this.config.getShellExecutionSandbox?.() && + commitCtx.attributableInCwd + ) { // `git commit --amend` rewrites HEAD in place, so the standard // parent-vs-postHead diff (`${postHead}~1..${postHead}`) would // span the entire amended commit (the amended commit's parent @@ -3175,6 +3192,7 @@ export class ShellToolInvocation extends BaseToolInvocation< preRunRepoKeys: AttributionRepoKeys | undefined, ghCreateEnv?: Readonly>, ): void { + if (this.config.getShellExecutionSandbox?.()) return; void (async () => { try { if (!commandRunsGhPrCreate(command)) return; @@ -3836,9 +3854,9 @@ export class ShellToolInvocation extends BaseToolInvocation< 'Stripped trailing & from background shell command — managed path handles backgrounding', ); } - const processedCommand = this.addAttributionToPR( - this.addCoAuthorToGitCommit(noTrailingAmp), - ); + const processedCommand = this.config.getShellExecutionSandbox?.() + ? noTrailingAmp + : this.addAttributionToPR(this.addCoAuthorToGitCommit(noTrailingAmp)); const cwd = this.params.directory || this.config.getTargetDir(); // Output goes under the project temp dir (which `ReadFileTool` @@ -3887,32 +3905,47 @@ export class ShellToolInvocation extends BaseToolInvocation< abortController: entryAc, }; - const { result: resultPromise, pid } = await ShellExecutionService.execute( - processedCommand, - cwd, - (event: ShellOutputEvent) => { - if (event.type === 'data' && typeof event.chunk === 'string') { - // Strip ANSI escape codes (color, cursor-move, clear-screen) before - // writing — agents read the file as plain text, and dev servers / - // build tools spam plenty of escape sequences that would render as - // garbage. Costs ~one regex per chunk; cheap relative to disk I/O. - outputStream.write(stripAnsi(event.chunk)); - } - // ANSI array chunks and binary streams are not written to the output - // file: agents read the file as plain text and binary spam would be - // unhelpful. - }, - entryAc.signal, - // Background shells are non-interactive by design — no terminal to - // attach a PTY to, no human to type at it. Force the child_process - // path so we don't pull in node-pty for fire-and-forget commands. - false, - shellExecutionConfig ?? {}, - // Stream stdout/stderr through to the output file as chunks arrive. - // Default child_process mode buffers until exit, which would leave - // dev-server / watcher output files empty until the process dies. - { streamStdout: true }, - ); + let executionHandle; + try { + executionHandle = await executeRuntimeShell( + this.config, + processedCommand, + cwd, + (event: ShellOutputEvent) => { + if (event.type === 'data' && typeof event.chunk === 'string') { + // Strip ANSI escape codes (color, cursor-move, clear-screen) before + // writing — agents read the file as plain text, and dev servers / + // build tools spam plenty of escape sequences that would render as + // garbage. Costs ~one regex per chunk; cheap relative to disk I/O. + outputStream.write(stripAnsi(event.chunk)); + } + // ANSI array chunks and binary streams are not written to the output + // file: agents read the file as plain text and binary spam would be + // unhelpful. + }, + entryAc.signal, + // Background shells are non-interactive by design — no terminal to + // attach a PTY to, no human to type at it. Force the child_process + // path so we don't pull in node-pty for fire-and-forget commands. + false, + shellExecutionConfig ?? {}, + // Stream stdout/stderr through to the output file as chunks arrive. + // Default child_process mode buffers until exit, which would leave + // dev-server / watcher output files empty until the process dies. + { streamStdout: true }, + ); + } catch (error) { + outputStream.destroy(); + try { + fs.rmSync(outputPath, { force: true }); + } catch (cleanupError) { + debugLogger.warn( + `background shell ${shellId} output cleanup failed: ${getErrorMessage(cleanupError)}`, + ); + } + throw error; + } + const { result: resultPromise, pid } = executionHandle; if (pid !== undefined) registration.pid = pid; const registry = this.config.getBackgroundShellRegistry(); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index d10ae850c6d..092c8e5752c 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -8,6 +8,10 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; +import { + captureRuntimeFileVersion, + writeRuntimeFile, +} from '../sandbox/runtime-file.js'; import { isAnyAutoMemPath, isTeamAutoMemPath } from '../memory/paths.js'; import { checkTeamMemorySecrets } from '../memory/team-memory-secret-guard.js'; import type { @@ -292,10 +296,28 @@ class WriteFileToolInvocation extends BaseToolInvocation< return confirmationDetails; } - async execute(_abortSignal: AbortSignal): Promise { + async execute(abortSignal: AbortSignal): Promise { const { file_path, content, ai_proposed_content, modified_by_user } = this.params; + let sandboxFileVersion: ReturnType; + try { + sandboxFileVersion = captureRuntimeFileVersion(this.config, file_path); + } catch (error) { + const message = `${file_path}: ${getErrorMessage(error)}`; + return { + llmContent: message, + returnDisplay: message, + error: { + message, + type: + isNodeError(error) && error.code === 'EISDIR' + ? ToolErrorType.TARGET_IS_DIRECTORY + : ToolErrorType.FILE_WRITE_FAILURE, + }, + }; + } + let fileExists = await isFilefileExists(file_path); let originalContent = ''; let useBOM = false; @@ -467,14 +489,9 @@ class WriteFileToolInvocation extends BaseToolInvocation< // // It does NOT eliminate the race. A concurrent writer that // lands between this stat and the writeTextFile call below - // can still be clobbered — that residual is an OS-level - // limitation of the stat-then-write pattern, and the only way - // to close it is an atomic write (write-to-temp + rename) or - // a content-hash post-check that re-reads the bytes after the - // write. Both are deferred to a follow-up; operators who care - // about strict overwrite-protection should set - // `fileReadCacheDisabled: true` and rely on application-level - // locking. + // can still be clobbered. Atomic replacement is not compare-and-swap; + // the sandbox worker rechecks the prepared version before commit, + // but strict protection against concurrent writers requires locking. // // Run unconditionally (not gated on `fileExists`): if the path // was absent during the earlier checkPriorRead but a different @@ -518,21 +535,26 @@ class WriteFileToolInvocation extends BaseToolInvocation< // directories on the failure path (rejected new-file writes // would otherwise litter the filesystem with empty mkdir'd // ancestors). - if (!fileExists) { + if (!fileExists && !this.config.getShellExecutionSandbox?.()) { fs.mkdirSync(dirName, { recursive: true }); } try { - await this.config.getFileSystemService().writeTextFile({ - path: file_path, - content, - toolWriteOrigin: 'write_file', - _meta: { - bom: useBOM, - encoding: detectedEncoding, - lineEnding: detectedLineEnding, + await writeRuntimeFile( + this.config, + { + path: file_path, + content, + toolWriteOrigin: 'write_file', + _meta: { + bom: useBOM, + encoding: detectedEncoding, + lineEnding: detectedLineEnding, + }, }, - }); + sandboxFileVersion, + abortSignal, + ); // Track AI contribution for commit attribution. // Pass null only when the file truly did not exist before this write; @@ -653,7 +675,12 @@ class WriteFileToolInvocation extends BaseToolInvocation< errorMsg = `Error writing to file '${file_path}': ${error.message} (${error.code})`; // Log specific error types for better debugging - if (error.code === 'EACCES') { + if ( + error.code === 'ESTALE' && + this.config.getShellExecutionSandbox?.() + ) { + errorType = ToolErrorType.FILE_CHANGED_SINCE_READ; + } else if (error.code === 'EACCES') { errorMsg = `Permission denied writing to file: ${file_path} (${error.code})`; errorType = ToolErrorType.PERMISSION_DENIED; } else if (error.code === 'ENOSPC') { diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 9fafb8b7f8b..e0bb1ac5c8c 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -1164,6 +1164,31 @@ describe('fileUtils', () => { actualNodeFs.unlinkSync(testBinaryFilePath); }); + it.each([undefined, '1-2'])( + 'rejects sandbox PDF processing before invoking host helpers (pages=%s)', + async (pages) => { + actualNodeFs.writeFileSync(testPdfFilePath, '%PDF-1.4\n'); + const config = { + ...mockConfig, + getShellExecutionSandbox: () => ({ + workspace: tempRootDir, + installation: '/installation', + state: '/state', + filesystem: 'workspace-write', + network: 'closed', + }), + } as Config; + const result = await processSingleFileContent(testPdfFilePath, config, { + fileType: 'pdf', + pages, + }); + expect(result.errorType).toBe(ToolErrorType.READ_CONTENT_FAILURE); + expect(result.error).toContain('sandboxed Shell'); + expect(execFile).not.toHaveBeenCalled(); + expect(mockRender).not.toHaveBeenCalled(); + }, + ); + it('should read a text file successfully', async () => { const content = 'Line 1\\nLine 2\\nLine 3'; actualNodeFs.writeFileSync(testTextFilePath, content); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 06af0cfe421..3420593f569 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -1230,6 +1230,16 @@ export async function processSingleFileContent( ? 'text' : (options.fileType ?? (bridgePreservesImage ? 'image' : await detectFileType(filePath))); + if (fileType === 'pdf' && config.getShellExecutionSandbox?.()) { + const message = + 'PDF processing is unavailable in this sandbox mode. Use a PDF utility through the sandboxed Shell tool.'; + return { + llmContent: message, + returnDisplay: message, + error: message, + errorType: ToolErrorType.READ_CONTENT_FAILURE, + }; + } const shouldRenderImageOverview = fileType === 'image' && CANONICAL_IMAGE_MIME_TYPES.has(mediaMimeType); const displayName = path.basename(displayPath); diff --git a/scripts/sandbox-runtime/README.md b/scripts/sandbox-runtime/README.md new file mode 100644 index 00000000000..d1d20e2deb3 --- /dev/null +++ b/scripts/sandbox-runtime/README.md @@ -0,0 +1,27 @@ +# Internal runtime tool sandbox acceptance + +These test scripts exercise the production `loadCliConfig`, authentication, noninteractive model/tool loop, Read/Write/Edit/Shell tools, and background task registry. They inject the trusted host policy through a test launcher. The ordinary public CLI does not accept this internal mode. + +## Prerequisites + +- Install repository development dependencies and build workspace packages with Node.js 22 or newer. +- Run the candidate on Linux with working unprivileged bubblewrap at `/usr/bin/bwrap`, Node.js at `/usr/bin/node`, and Bash at `/bin/bash`. +- Keep the installation, workspace, runtime state, and HOME directories separate. Tests create their own disposable fixtures under `/tmp`. +- If testing native PTY transport, provide the Linux `@lydell/node-pty` dependency in the test installation's `node_modules`; do not reuse macOS native packages. The separate `scripts/sandbox-prototype` verifier checks both transport implementations. + +No model credentials or internet endpoint are needed. The driver serves a deterministic OpenAI-compatible endpoint over loopback and supplies only a synthetic API key. It clears ambient environment variables and creates an isolated HOME/runtime for each case. It does not change kernel configuration, AppArmor, or shared dependencies. + +## Before implementation baseline + +Run `node scripts/sandbox-runtime/baseline.mjs` on the development host with a global `qwen` installation. It drives the global CLI through a real model/tool turn and confirms the ordinary baseline can write both inside the disposable workspace and to a sibling directory. Evidence goes under `.qwen/e2e-tests/runtime-shell-sandbox` by default; an optional first argument overrides that output directory. Preserve the dated baseline evidence before implementing the feature. + +## Build and verify a candidate + +1. Run `node scripts/sandbox-runtime/build.mjs /absolute/empty/installation` from the exact source revision under test. The directory must be empty. The builder resolves repository paths from its own location and bundles the actual CLI and core sources into one production headless launcher. The manifest records the Git revision, worktree dirtiness, source inputs, all test scripts, and output hashes. +2. Copy the complete installation to the Linux host if necessary. It must be outside the test workspaces. Link Linux native dependencies into this owned installation when possible. If they must be installed there, use npm with `--no-save --package-lock=false`; changing the manifest-tracked `package.json` invalidates the candidate by design. +3. Keep the exact source checkout available on Linux, then run `node /absolute/installation/verify.mjs /absolute/installation /tmp/runtime-shell-report.json /absolute/source-checkout`. The verifier refuses a different revision, dirty state, or source-input hash before exercising the candidate. Store output on a writable Linux filesystem. Lima may mount the host repository read-only; copy the report back afterward. +4. Retain stdout and the JSON report under `.qwen/e2e-tests`. The driver verifies artifact hashes again after execution, and exits nonzero on any failed check. + +The checks cover workspace/network enforcement, host model/session persistence, repository clean filters during prompt startup, sed and unsupported NotebookEdit behavior, read-only/YOLO, two Config instances in the same process, ignored ambient startup effects, explicit unsupported MCP/extensions/LSP/ACP startup rejection, setup failure without replay, and real ShellTool cancellation/timeout/background/promotion/receipt-loss lifecycle. File checks cover Read-based freshness, new-file creation, UTF-8 and UTF-16 encoding/BOM/CRLF preservation, modes, relative and dangling symlinks, direct and symlinked outside-write denial without directory or temporary-file litter, read-only/YOLO, content larger than one MiB, special-file rejection, PDF host-helper exclusion, and setup failure without host fallback. Both Config instances also exercise independent file-write grants. The Git fixture confirms that the filter is exercised by an explicit confined Git tool call while its outside write is denied. Namespace cleanup checks compare `/proc/self/ns/pid` identities and reject the host namespace before enumerating descendants. + +Fixtures and their stdout/stderr/session files remain under the unique `/tmp/qwen-runtime-shell-candidate-*` directory named in the report for inspection. Remove only that owned directory and the owned installation when the evidence is archived. The driver confirms all controlled payload namespaces have no remaining descendants. Production source is never edited by these scripts. diff --git a/scripts/sandbox-runtime/baseline.mjs b/scripts/sandbox-runtime/baseline.mjs new file mode 100644 index 00000000000..93d5c33e61c --- /dev/null +++ b/scripts/sandbox-runtime/baseline.mjs @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fileURLToPath } from 'node:url'; +import http from 'node:http'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; + +const output = + process.argv[2] ?? + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../.qwen/e2e-tests/runtime-shell-sandbox', + ); +await fs.mkdir(output, { recursive: true }); +const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-runtime-shell-baseline-'), +); +const workspace = path.join(root, 'workspace'); +const outside = path.join(root, 'outside'); +const home = path.join(root, 'home'); +const runtime = path.join(root, 'runtime'); +await Promise.all([workspace, outside, home, runtime].map((p) => fs.mkdir(p))); +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; +const command = `printf workspace-ok > ${quote(path.join(workspace, 'inside.txt'))}; printf outside-ok > ${quote(path.join(outside, 'outside.txt'))}; printf baseline-executed`; +const requests = []; +const server = http.createServer(async (req, res) => { + let raw = ''; + for await (const data of req) raw += data; + const body = JSON.parse(raw); + const tools = body.messages?.filter((m) => m.role === 'tool') ?? []; + requests.push({ + url: req.url, + tools: body.tools?.map((t) => t.function?.name), + toolResults: tools, + stream: body.stream, + }); + const toolCalls = tools.length + ? undefined + : [ + { + id: 'call_baseline_shell', + type: 'function', + function: { + name: 'run_shell_command', + arguments: JSON.stringify({ + command, + is_background: false, + timeout: 10000, + }), + }, + }, + ]; + const message = { + role: 'assistant', + content: tools.length ? 'BASELINE_COMPLETE' : '', + ...(toolCalls ? { tool_calls: toolCalls } : {}), + }; + const common = { + id: 'chatcmpl-baseline', + created: Math.floor(Date.now() / 1000), + model: 'mock-model', + }; + const finish = toolCalls ? 'tool_calls' : 'stop'; + const usage = { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + }; + if (!body.stream) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + ...common, + object: 'chat.completion', + choices: [{ index: 0, message, finish_reason: finish }], + usage, + }), + ); + } else { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + const send = (delta, finish_reason = null, extra = {}) => + res.write( + `data: ${JSON.stringify({ ...common, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`, + ); + send({ role: 'assistant', content: '' }); + if (toolCalls) + send({ tool_calls: toolCalls.map((t, index) => ({ index, ...t })) }); + else send({ content: message.content }); + send({}, finish, { usage }); + res.end('data: [DONE]\n\n'); + } +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const port = server.address().port; +const binary = spawnSync('/bin/zsh', ['-lc', 'command -v qwen'], { + encoding: 'utf8', +}).stdout.trim(); +const version = spawnSync(binary, ['--version'], { + encoding: 'utf8', +}).stdout.trim(); +const args = [ + '--bare', + '--auth-type', + 'openai', + '--openai-base-url', + `http://127.0.0.1:${port}/v1`, + '--openai-api-key', + 'sk-mock', + '--model', + 'mock-model', + '--approval-mode', + 'yolo', + '--output-format', + 'json', + '-p', + 'Run the disposable fixture check', +]; +const env = { + PATH: process.env.PATH, + HOME: home, + QWEN_RUNTIME_DIR: runtime, + TMPDIR: root, + LANG: 'en_US.UTF-8', + TERM: 'dumb', + NO_PROXY: '127.0.0.1,localhost', + CI: 'true', +}; +let stdout = '', + stderr = ''; +const child = spawn(binary, args, { + cwd: workspace, + env, + stdio: ['ignore', 'pipe', 'pipe'], +}); +child.stdout.on('data', (chunk) => (stdout += chunk)); +child.stderr.on('data', (chunk) => (stderr += chunk)); +const timer = setTimeout(() => child.kill('SIGKILL'), 60000); +const result = await new Promise((resolve) => + child.on('exit', (code, signal) => resolve({ code, signal })), +); +clearTimeout(timer); +server.closeAllConnections(); +await new Promise((resolve) => server.close(resolve)); +const read = async (p) => fs.readFile(p, 'utf8').catch(() => null); +const inside = await read(path.join(workspace, 'inside.txt')); +const outsideValue = await read(path.join(outside, 'outside.txt')); +const report = { + time: new Date().toISOString(), + root, + binary, + version, + args, + env, + command, + result, + requests, + inside, + outside: outsideValue, + pass: + result.code === 0 && + inside === 'workspace-ok' && + outsideValue === 'outside-ok' && + requests.some((r) => r.toolResults.length), +}; +await Promise.all([ + fs.writeFile( + path.join(output, 'baseline.json'), + JSON.stringify(report, null, 2), + ), + fs.writeFile(path.join(output, 'baseline.stdout.log'), stdout), + fs.writeFile(path.join(output, 'baseline.stderr.log'), stderr), +]); +console.log(JSON.stringify(report, null, 2)); +if (!report.pass) process.exitCode = 1; diff --git a/scripts/sandbox-runtime/build.mjs b/scripts/sandbox-runtime/build.mjs new file mode 100644 index 00000000000..5d478cb8e9d --- /dev/null +++ b/scripts/sandbox-runtime/build.mjs @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import { wasmLoader } from 'esbuild-plugin-wasm'; +import { createRequire } from 'node:module'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { hashFile, readSourceIdentity } from './source-manifest.mjs'; + +const source = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(source, '../..'); +const output = process.argv[2]; +if (!output || !path.isAbsolute(output)) + throw new Error('Supply empty absolute artifact directory'); +await fs.mkdir(output, { recursive: true }); +if ((await fs.readdir(output)).length) + throw new Error('Artifact directory must be empty'); +const require = createRequire(import.meta.url); +const coreExports = JSON.parse( + await fs.readFile(path.join(root, 'packages/core/package.json'), 'utf8'), +).exports; +const coreSource = (specifier) => { + const key = + specifier === '@qwen-code/qwen-code-core' + ? '.' + : './' + specifier.slice('@qwen-code/qwen-code-core/'.length); + const entry = coreExports[key]; + const file = typeof entry === 'string' ? entry : entry?.import; + if (key === '.') return path.join(root, 'packages/core/src/index.ts'); + return path.join( + root, + 'packages/core', + (file ?? './src/' + key.slice(2)) + .replace('./dist/src/', './src/') + .replace(/\.js$/, '.ts'), + ); +}; +const common = { + absWorkingDir: root, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + metafile: true, + external: [ + '@lydell/node-pty', + 'node-pty', + '@qwen-code/audio-capture', + '@teddyzhu/clipboard', + 'sharp', + ], + inject: [path.join(root, 'scripts/esbuild-shims.js')], + define: { + 'process.env.NODE_ENV': '"production"', + 'process.env.CLI_VERSION': '"0.23.4-internal-test"', + global: 'globalThis', + __dirname: '__qwen_dirname', + __filename: '__qwen_filename', + }, + loader: { '.node': 'file' }, + alias: { + 'is-in-ci': path.join(root, 'packages/cli/src/patches/is-in-ci.ts'), + 'jsonc-parser': require.resolve('jsonc-parser/lib/esm/main.js'), + '@qwen-code/web-templates': path.join( + root, + 'packages/web-templates/src/index.ts', + ), + punycode: require.resolve('punycode/'), + }, + plugins: [ + { + name: 'actual-core-source', + setup(b) { + b.onResolve( + { filter: /^@qwen-code\/qwen-code-core(?:\/|$)/ }, + (args) => ({ path: coreSource(args.path) }), + ); + }, + }, + { + name: 'wasm-binary', + setup(b) { + b.onResolve({ filter: /\.wasm\?binary$/ }, (args) => ({ + path: createRequire(path.join(args.resolveDir, '_dummy_.js')).resolve( + args.path.replace(/\?binary$/, ''), + ), + namespace: 'wasm-binary', + })); + b.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({ + contents: await fs.readFile(args.path), + loader: 'binary', + })); + }, + }, + wasmLoader({ mode: 'embedded' }), + ], +}; +const result = await build({ + ...common, + entryPoints: [path.join(source, 'launcher.mjs')], + outfile: path.join(output, 'launcher.mjs'), +}); +const workers = await build({ + ...common, + entryPoints: { + sandboxBwrapRelay: 'packages/core/src/sandbox/bwrap-relay.ts', + sandboxFileWorker: 'packages/core/src/sandbox/file-worker.ts', + }, + outdir: output, +}); +await fs.writeFile(path.join(output, 'package.json'), '{"type":"module"}\n'); +const inputs = {}; +for (const file of Object.keys({ + ...result.metafile.inputs, + ...workers.metafile.inputs, +})) { + if (file.startsWith('wasm-binary:')) continue; + inputs[file] = await hashFile(path.resolve(root, file)); +} +for (const name of (await fs.readdir(source)) + .filter((name) => name.endsWith('.mjs') || name === 'README.md') + .sort()) { + const file = path.join(source, name); + inputs[path.relative(root, file)] = await hashFile(file); +} +for (const name of [ + 'package.json', + 'pnpm-lock.yaml', + 'tsconfig.json', + 'packages/core/package.json', + 'packages/cli/package.json', +]) { + inputs[name] = await hashFile(path.join(root, name)); +} +const scriptsTests = path.join(root, 'scripts/tests'); +for (const name of (await fs.readdir(scriptsTests)) + .filter((name) => /^sandbox-runtime-.*\.test\.js$/.test(name)) + .sort()) { + const file = path.join(scriptsTests, name); + inputs[path.relative(root, file)] = await hashFile(file); +} +await fs.copyFile( + path.join(source, 'verify.mjs'), + path.join(output, 'verify.mjs'), +); +await fs.copyFile( + path.join(source, 'source-manifest.mjs'), + path.join(output, 'source-manifest.mjs'), +); +const artifacts = {}; +for (const file of await fs.readdir(output)) + artifacts[file] = await hashFile(path.join(output, file)); +const sourceIdentity = readSourceIdentity(root); +const manifestPath = path.join(output, 'manifest.json'); +await fs.writeFile( + manifestPath, + JSON.stringify( + { + created: new Date().toISOString(), + ...sourceIdentity, + inputs, + artifacts, + }, + null, + 2, + ), +); +const manifestSha256 = await hashFile(manifestPath); +if (sourceIdentity.dirty) + console.warn('WARNING: candidate built from a dirty worktree.'); +console.log( + JSON.stringify( + { output, inputs: Object.keys(inputs).length, artifacts, manifestSha256 }, + null, + 2, + ), +); diff --git a/scripts/sandbox-runtime/launcher.mjs b/scripts/sandbox-runtime/launcher.mjs new file mode 100644 index 00000000000..1aa3562619f --- /dev/null +++ b/scripts/sandbox-runtime/launcher.mjs @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { loadCliConfig } from '../../packages/cli/src/config/config.js'; +import { LoadedSettings } from '../../packages/cli/src/config/settings.js'; +import { runNonInteractive } from '../../packages/cli/src/nonInteractiveCli.js'; +import { AuthType } from '../../packages/core/src/utils/auth-type.js'; +import { runExitCleanup } from '../../packages/cli/src/utils/cleanup.js'; + +const spec = JSON.parse(await fs.readFile(process.argv[2], 'utf8')); +const installation = path.dirname(fileURLToPath(import.meta.url)); +const settingsFile = (name) => ({ + path: path.join(spec.home, name), + settings: name === 'user.json' ? (spec.settings ?? {}) : {}, +}); +const settings = new LoadedSettings( + settingsFile('system.json'), + settingsFile('defaults.json'), + settingsFile('user.json'), + settingsFile('workspace.json'), + true, + new Set(), +); +const argv = { + bare: true, + approvalMode: 'yolo', + authType: 'openai', + openaiApiKey: 'sk-mock', + openaiBaseUrl: spec.baseUrl, + model: 'mock-model', + prompt: spec.prompt ?? 'Run the disposable fixture check', + outputFormat: 'json', + chatRecording: true, + telemetry: false, + ...(spec.argv ?? {}), +}; +const policy = { + workspace: spec.workspace, + state: spec.state, + installation, + filesystem: spec.filesystem ?? 'workspace-write', + network: 'closed', + ...(spec.bwrapPath ? { bwrapPath: spec.bwrapPath } : {}), +}; +const loadRuntime = (workspace, baseUrl = spec.baseUrl) => + loadCliConfig( + spec.settings ?? {}, + { ...argv, openaiBaseUrl: baseUrl }, + workspace, + argv.extensions, + spec.hooks, + undefined, + undefined, + undefined, + false, + { shellExecutionSandbox: { ...policy, workspace } }, + ); +let config = await loadRuntime(spec.workspace); +const exists = (p) => + fs.access(p).then( + () => true, + () => false, + ); +async function until(predicate, label) { + for (let i = 0; i < 400; i++) { + if (await predicate()) return; + await delay(25); + } + throw new Error(`Timed out: ${label}`); +} +let summary; +try { + await config.refreshAuth(AuthType.USE_OPENAI, true); + await config.initialize(); + if (spec.startupOnly) { + summary = { code: 0, initialized: true }; + } else if (!spec.direct) { + const code = await runNonInteractive( + config, + settings, + argv.prompt, + `${config.getSessionId()}-acceptance`, + ); + summary = { + code, + sessionId: config.getSessionId(), + tasks: config.getBackgroundShellRegistry().getAll(), + }; + if (spec.second) { + const first = summary; + config = await loadRuntime(spec.second.workspace, spec.second.baseUrl); + await config.refreshAuth(AuthType.USE_OPENAI, true); + await config.initialize(); + const secondCode = await runNonInteractive( + config, + settings, + argv.prompt, + `${config.getSessionId()}-second`, + ); + summary = { + code: code || secondCode, + runtimes: [ + first, + { + code: secondCode, + sessionId: config.getSessionId(), + tasks: config.getBackgroundShellRegistry().getAll(), + }, + ], + }; + } + } else { + const registry = config.getToolRegistry(); + const shell = registry.getTool('run_shell_command'); + if (!shell) throw new Error('Production shell tool missing'); + const ac = new AbortController(); + let promoteAc, pid; + const promise = shell.build(spec.direct.params).execute( + ac.signal, + undefined, + undefined, + (value) => { + pid = value; + }, + (value) => { + promoteAc = value; + }, + () => true, + ); + void promise.catch(() => {}); + if (spec.direct.ready) + await until(() => exists(spec.direct.ready), 'payload ready'); + if (spec.direct.promote) { + await until(() => !!promoteAc, 'promotion controller'); + promoteAc.abort({ kind: 'background' }); + } + if (spec.direct.cancel) ac.abort({ kind: 'user' }); + if (spec.direct.removeReceipt) { + const controls = (await fs.readdir(spec.state)).filter((name) => + name.startsWith('sandbox-control-'), + ); + if (controls.length !== 1) + throw new Error(`Expected one receipt directory, got ${controls}`); + await fs.unlink(path.join(spec.state, controls[0], 'status.json')); + } + if (spec.direct.gate) await fs.writeFile(spec.direct.gate, 'go'); + const result = await promise; + if (spec.direct.taskStop) { + const task = config + .getBackgroundShellRegistry() + .getAll() + .find((task) => task.status === 'running'); + if (!task) throw new Error('No running production background task'); + const stop = registry.getTool('task_stop'); + if (!stop) throw new Error('Production task_stop missing'); + await stop + .build({ task_id: task.id }) + .execute(new AbortController().signal); + } + await until( + () => + config + .getBackgroundShellRegistry() + .getAll() + .every((task) => task.status !== 'running'), + 'background terminal state', + ); + const tasks = config.getBackgroundShellRegistry().getAll(); + summary = { + code: 0, + sessionId: config.getSessionId(), + pid, + result, + tasks, + policy: config.getShellExecutionSandbox?.(), + }; + } +} catch (error) { + summary = { code: 1, error: error.stack }; +} finally { + await fs.writeFile( + spec.summary, + JSON.stringify( + summary, + (_key, value) => + value instanceof Error ? { message: value.message } : value, + 2, + ), + ); + await runExitCleanup(); +} +process.exit(summary.code); diff --git a/scripts/sandbox-runtime/source-manifest.mjs b/scripts/sandbox-runtime/source-manifest.mjs new file mode 100644 index 00000000000..383eab18735 --- /dev/null +++ b/scripts/sandbox-runtime/source-manifest.mjs @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export const hashFile = async (file) => + createHash('sha256') + .update(await fs.readFile(file)) + .digest('hex'); + +export function readSourceIdentity(root) { + return { + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8', + }).trim(), + dirty: + execFileSync( + 'git', + ['-c', 'status.showUntrackedFiles=normal', 'status', '--porcelain'], + { + cwd: root, + encoding: 'utf8', + }, + ).trim() !== '', + }; +} + +export async function verifySourceManifest(root, manifest) { + const canonicalRoot = await fs.realpath(root); + const identity = readSourceIdentity(canonicalRoot); + assert.equal( + identity.revision, + manifest.revision, + 'Source revision differs from the candidate build', + ); + const actualInputs = {}; + for (const name of Object.keys(manifest.inputs)) { + if (path.isAbsolute(name)) { + throw new Error(`Source manifest input must be relative: ${name}`); + } + const file = path.resolve(canonicalRoot, name); + const relative = path.relative(canonicalRoot, file); + if (relative === '..' || relative.startsWith(`..${path.sep}`)) { + throw new Error(`Source manifest input escapes the source root: ${name}`); + } + try { + actualInputs[name] = await hashFile(file); + } catch (error) { + throw new Error( + `Source manifest input is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + assert.deepEqual( + actualInputs, + manifest.inputs, + 'Source inputs differ from the candidate build', + ); + assert.equal( + identity.dirty, + manifest.dirty, + 'Source worktree dirtiness differs from the candidate build', + ); + return { ...identity, inputs: actualInputs }; +} diff --git a/scripts/sandbox-runtime/verify.mjs b/scripts/sandbox-runtime/verify.mjs new file mode 100644 index 00000000000..3d4b12645ba --- /dev/null +++ b/scripts/sandbox-runtime/verify.mjs @@ -0,0 +1,1196 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import { verifySourceManifest } from './source-manifest.mjs'; + +const installation = process.argv[2]; +const output = process.argv[3]; +const sourceRoot = process.argv[4]; +if (!installation || !output || !sourceRoot) + throw new Error('Usage: verify.mjs INSTALLATION OUTPUT SOURCE_ROOT'); +const manifestPath = path.join(installation, 'manifest.json'); +const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); +let source; +try { + source = await verifySourceManifest(sourceRoot, manifest); +} catch (error) { + await fs.writeFile( + output, + JSON.stringify( + { + time: new Date().toISOString(), + installation, + sourceRoot, + sourceVerification: { + passed: false, + error: error instanceof Error ? error.stack : String(error), + }, + }, + null, + 2, + ), + ); + throw error; +} +const manifestSha256 = createHash('sha256') + .update(await fs.readFile(manifestPath)) + .digest('hex'); +const uid = process.geteuid?.(); +if (uid === 0) { + const error = new Error('Run the sandbox verifier as an unprivileged user'); + await fs.writeFile( + output, + JSON.stringify( + { + time: new Date().toISOString(), + installation, + sourceRoot, + uid, + manifestSha256, + source, + environmentVerification: { passed: false, error: error.message }, + }, + null, + 2, + ), + ); + throw error; +} +const root = await fs.mkdtemp('/tmp/qwen-runtime-shell-candidate-'); +const results = []; +const allRequests = []; +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; +const exists = (p) => + fs.access(p).then( + () => true, + () => false, + ); +const read = (p) => fs.readFile(p, 'utf8'); +const hash = async (p) => + createHash('sha256') + .update(await fs.readFile(p)) + .digest('hex'); +const hostNs = await fs.readlink('/proc/self/ns/pid'); +const server = http.createServer(async (req, res) => { + if (req.url === '/probe') { + res.end('HOST_MODEL_NETWORK'); + return; + } + let raw = ''; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + const name = req.url.split('/')[1]; + const scenario = scenarios.get(name); + if (!scenario) { + res.writeHead(404).end('unknown scenario'); + return; + } + const toolResults = body.messages?.filter((m) => m.role === 'tool') ?? []; + const request = { + scenario: name, + tools: body.tools?.map((t) => t.function?.name), + toolResults, + stream: body.stream, + startupMarkerPresent: scenario.startupMarker + ? await exists(scenario.startupMarker) + : undefined, + }; + allRequests.push(request); + const step = scenario.steps.find( + (_, index) => + !toolResults.some((t) => t.tool_call_id === `call_${name}_${index}`), + ); + const index = scenario.steps.indexOf(step); + if (step?.before && !step.prepared) { + await step.before(); + step.prepared = true; + } + const calls = step + ? [ + { + id: `call_${name}_${index}`, + type: 'function', + function: { + name: step.name ?? 'run_shell_command', + arguments: JSON.stringify( + step.args ?? { + command: step.command, + is_background: false, + timeout: 10000, + }, + ), + }, + }, + ] + : undefined; + const common = { + id: `chatcmpl-${name}`, + created: Math.floor(Date.now() / 1000), + model: 'mock-model', + }; + const content = calls ? '' : `COMPLETE_${name}`; + const finish = calls ? 'tool_calls' : 'stop'; + const usage = { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + }; + if (!body.stream) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + ...common, + object: 'chat.completion', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content, + ...(calls ? { tool_calls: calls } : {}), + }, + finish_reason: finish, + }, + ], + usage, + }), + ); + } else { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + const send = (delta, finish_reason = null, extra = {}) => + res.write( + `data: ${JSON.stringify({ ...common, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`, + ); + send({ role: 'assistant', content: '' }); + if (calls) + send({ tool_calls: calls.map((call, index) => ({ index, ...call })) }); + else send({ content }); + send({}, finish, { usage }); + res.end('data: [DONE]\n\n'); + } +}); +const scenarios = new Map(); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const port = server.address().port; + +async function fixture(name) { + const dir = path.join(root, name); + const workspace = path.join(dir, 'workspace'); + const outside = path.join(dir, 'outside'); + const home = path.join(dir, 'home'); + const state = path.join(dir, 'state'); + await Promise.all( + [workspace, outside, home, state].map((p) => + fs.mkdir(p, { recursive: true }), + ), + ); + return { + name, + dir, + workspace, + outside, + home, + state, + summary: path.join(dir, 'summary.json'), + baseUrl: `http://127.0.0.1:${port}/${name}/v1`, + }; +} +async function invoke(f, spec = {}, steps = []) { + scenarios.set(f.name, { steps, startupMarker: f.startupMarker }); + const specPath = path.join(f.dir, 'spec.json'); + await fs.writeFile(specPath, JSON.stringify({ ...f, ...spec })); + const env = { + PATH: f.helperPath ? `${f.helperPath}:/usr/bin:/bin` : '/usr/bin:/bin', + HOME: f.home, + QWEN_RUNTIME_DIR: f.state, + TMPDIR: '/tmp', + TERM: 'xterm-256color', + LANG: 'C.UTF-8', + NO_PROXY: '127.0.0.1,localhost', + CI: 'true', + }; + let stdout = '', + stderr = ''; + const child = spawn( + '/usr/bin/node', + [path.join(installation, 'launcher.mjs'), specPath], + { + cwd: f.workspace, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }, + ); + child.stdout.on('data', (data) => (stdout += data)); + child.stderr.on('data', (data) => (stderr += data)); + const timer = setTimeout(() => { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + // The owned launcher may have exited before the timeout fires. + } + }, 45000); + const exit = await new Promise((resolve) => + child.on('exit', (code, signal) => resolve({ code, signal })), + ); + clearTimeout(timer); + await Promise.all([ + fs.writeFile(path.join(f.dir, 'stdout.log'), stdout), + fs.writeFile(path.join(f.dir, 'stderr.log'), stderr), + ]); + const summary = await read(f.summary).then(JSON.parse, () => undefined); + return { + ...exit, + stdout, + stderr, + summary, + requests: allRequests.filter((request) => request.scenario === f.name), + }; +} +async function filesUnder(p) { + const files = []; + for (const item of await fs.readdir(p, { withFileTypes: true })) { + const child = path.join(p, item.name); + if (item.isDirectory()) files.push(...(await filesUnder(child))); + else if (item.isFile()) files.push(child); + } + return files; +} +async function check(name, run) { + try { + const evidence = await run(); + results.push({ name, passed: true, evidence }); + } catch (error) { + results.push({ name, passed: false, error: error.stack }); + } + console.log(`${results.at(-1).passed ? 'PASS' : 'FAIL'} ${name}`); +} +function pipelineOkay(run) { + assert.equal(run.code, 0, run.stderr + '\n' + run.stdout); + assert.ok( + run.requests.length >= 2, + 'Model must receive actual tool response', + ); + const name = run.requests[0].scenario; + const last = run.requests.at(-1).toolResults; + for (const index of scenarios.get(name).steps.keys()) { + assert.ok( + last.some((result) => result.tool_call_id === `call_${name}_${index}`), + `Missing production result for ${name} step ${index}`, + ); + } + assert.deepEqual([...new Set(run.requests[0].tools)].sort(), [ + 'agent', + 'edit', + 'glob', + 'list_directory', + 'monitor', + 'read_file', + 'run_shell_command', + 'task_stop', + 'write_file', + ]); +} +async function noNamespaceProcesses(ns) { + assert.match(ns, /^pid:\[\d+\]$/); + assert.notEqual( + ns, + hostNs, + 'Never enumerate payload processes using host namespace', + ); + for (let tries = 0; tries < 160; tries++) { + const matching = []; + for (const name of await fs.readdir('/proc')) { + if (!/^\d+$/.test(name)) continue; + try { + if ((await fs.readlink(`/proc/${name}/ns/pid`)) === ns) + matching.push(name); + } catch { + // A process can exit between listing /proc and reading its namespace. + } + } + if (!matching.length) return; + await delay(25); + } + throw new Error(`Payload descendants still present in ${ns}`); +} +async function controlled(f, mode) { + const ready = path.join(f.workspace, 'ready.json'); + const gate = path.join(f.workspace, 'gate'); + const once = path.join(f.workspace, 'once'); + const script = path.join(f.workspace, 'controlled.cjs'); + const auto = ['background-ok', 'background-fail'].includes(mode); + const exit = mode === 'background-fail' ? 7 : 0; + await fs.writeFile( + script, + `const fs=require('node:fs');require('node:child_process').spawn('/usr/bin/node',['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.appendFileSync(${JSON.stringify(once)},'x');fs.writeFileSync(${JSON.stringify(ready)},JSON.stringify({ns:fs.readlinkSync('/proc/self/ns/pid')}));const t=setInterval(()=>{if(${auto ? 'true' : `fs.existsSync(${JSON.stringify(gate)})`}){clearInterval(t);process.exit(${exit})}},${auto ? 500 : 25});`, + ); + const direct = { + params: { + command: `/usr/bin/node ${quote(script)}`, + is_background: mode.startsWith('background') || mode === 'task-stop', + timeout: mode === 'timeout' ? 1000 : 10000, + }, + ready, + }; + if (mode === 'promoted' || mode === 'promoted-lost') direct.promote = true; + if (mode === 'cancel') direct.cancel = true; + if (mode === 'task-stop') direct.taskStop = true; + if (['lost', 'background-lost', 'promoted-lost'].includes(mode)) + direct.removeReceipt = true; + if (['promoted', 'lost', 'background-lost', 'promoted-lost'].includes(mode)) + direct.gate = gate; + const run = await invoke(f, { direct }); + assert.equal(run.code, 0, run.stderr + '\n' + JSON.stringify(run.summary)); + assert.ok(run.summary, 'Must return actual ShellTool result'); + const ns = JSON.parse(await read(ready)).ns; + await noNamespaceProcesses(ns); + assert.equal(await read(once), 'x', 'Command must run once'); + const text = JSON.stringify(run.summary.result); + const tasks = run.summary.tasks; + if (mode === 'promoted' || mode === 'background-ok') + assert.equal(tasks[0]?.status, 'completed', JSON.stringify(tasks)); + if (['background-fail', 'background-lost', 'promoted-lost'].includes(mode)) + assert.equal(tasks[0]?.status, 'failed', JSON.stringify(tasks)); + if (mode === 'task-stop') + assert.equal(tasks[0]?.status, 'cancelled', JSON.stringify(tasks)); + if (mode === 'lost') + assert.match( + text, + /unconfirmed|not confirmed|could not.*confirm|receipt|may have run/i, + ); + if (mode === 'cancel') assert.match(text, /cancel|abort/i); + if (mode === 'timeout') assert.match(text, /timed out|timeout/i); + return { + namespace: ns, + once: 'x', + result: run.summary.result, + tasks, + descendantsGone: true, + }; +} +try { + await check( + 'production model/tool/session flow confines workspace and network', + async () => { + const f = await fixture('main'); + const inside = path.join(f.workspace, 'inside'); + const outside = path.join(f.outside, 'outside'); + const probe = path.join(f.workspace, 'net.cjs'); + await fs.writeFile( + probe, + `const net=require('node:net');const s=net.connect(${port},'127.0.0.1');s.on('connect',()=>{console.log('NETWORK_ESCAPED');s.destroy();process.exit(4)});s.on('error',()=>console.log('NETWORK_BLOCKED'));s.setTimeout(1000,()=>{console.log('NETWORK_BLOCKED');s.destroy()});`, + ); + const run = await invoke(f, {}, [ + { command: `printf allowed > ${quote(inside)}` }, + { command: `printf forbidden > ${quote(outside)}` }, + { command: `/usr/bin/node ${quote(probe)}` }, + ]); + pipelineOkay(run); + assert.equal(await read(inside), 'allowed'); + assert.equal(await exists(outside), false); + assert.match( + JSON.stringify(run.requests), + /Read-only file system|Permission denied/, + ); + assert.match(JSON.stringify(run.requests), /NETWORK_BLOCKED/); + assert.doesNotMatch(JSON.stringify(run.requests), /NETWORK_ESCAPED/); + const recordings = (await filesUnder(f.state)).filter((p) => + p.endsWith('.jsonl'), + ); + assert.ok(recordings.length, 'Host chat/session JSONL must persist'); + assert.ok( + (await Promise.all(recordings.map(read))).some((text) => + text.includes('run_shell_command'), + ), + 'Session recording must contain actual shell turn', + ); + return { + fixture: f.dir, + modelRequests: run.requests.length, + sessionId: run.summary.sessionId, + recordings, + inside: 'allowed', + outsideAbsent: true, + network: 'blocked', + }; + }, + ); + await check( + 'repository clean filters cannot execute on the host', + async () => { + const f = await fixture('git-filter'); + const marker = path.join(f.outside, 'filter-marker'); + const attempt = path.join(f.workspace, 'filter-attempt'); + const tracked = path.join(f.workspace, 'tracked.txt'); + const git = (...args) => + execFileSync('/usr/bin/git', args, { + cwd: f.workspace, + env: { + PATH: '/usr/bin:/bin', + HOME: f.home, + LANG: 'C.UTF-8', + GIT_CONFIG_NOSYSTEM: '1', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + git('init', '--quiet'); + await fs.writeFile(tracked, 'before\n'); + await fs.writeFile( + path.join(f.workspace, '.gitattributes'), + 'tracked.txt filter=fixture\n', + ); + git('add', '--', 'tracked.txt', '.gitattributes'); + git( + '-c', + 'user.name=Sandbox fixture', + '-c', + 'user.email=sandbox@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture baseline', + ); + git( + 'config', + '--local', + 'filter.fixture.clean', + `printf filter-ran >> ${quote(marker)}; printf attempted >> ${quote(attempt)}; cat`, + ); + git('config', '--local', 'filter.fixture.required', 'true'); + await fs.writeFile(tracked, 'after!\n'); + const changedTime = new Date(Date.now() + 5000); + await fs.utimes(tracked, changedTime, changedTime); + assert.equal(await exists(marker), false); + f.startupMarker = marker; + const run = await invoke(f, {}, [ + { command: 'printf SAFE_NO_GIT_TOOL_CALL' }, + { command: 'git status --short' }, + ]); + pipelineOkay(run); + assert.equal( + run.requests[0].startupMarkerPresent, + false, + 'Repository clean filter must not execute during host prompt startup', + ); + assert.ok(run.requests.every((request) => !request.startupMarkerPresent)); + assert.equal(await exists(marker), false); + assert.match(await read(attempt), /^(attempted)+$/); + assert.match( + JSON.stringify(run.requests), + /Read-only file system|Permission denied/, + 'Explicit confined git must exercise the configured filter', + ); + return { + fixture: f.dir, + beforeFirstModelReply: run.requests[0].startupMarkerPresent, + outsideMarkerAbsent: true, + confinedFilterAttempt: await read(attempt), + }; + }, + ); + await check('real sed cannot edit outside workspace', async () => { + const f = await fixture('sed'); + const file = path.join(f.outside, 'existing.txt'); + await fs.writeFile(file, 'before\n'); + const run = await invoke(f, {}, [ + { command: `sed -i 's/before/after/' ${quote(file)}` }, + ]); + pipelineOkay(run); + assert.equal(await read(file), 'before\n'); + assert.match( + JSON.stringify(run.requests), + /Read-only file system|Permission denied/, + ); + return { fixture: f.dir, unchanged: true }; + }); + await check( + 'unsupported NotebookEdit tool is rejected before a host write', + async () => { + const f = await fixture('notebook'); + const file = path.join(f.outside, 'existing.txt'); + await fs.writeFile(file, 'before\n'); + const run = await invoke(f, {}, [ + { + name: 'notebook_edit', + args: { + notebook_path: file, + new_source: 'after', + edit_mode: 'replace', + cell_number: 0, + }, + }, + ]); + pipelineOkay(run); + assert.equal(await read(file), 'before\n'); + assert.match( + JSON.stringify(run.requests), + /not found|unknown|not registered|not available/i, + ); + return { fixture: f.dir, unchanged: true, tools: run.requests[0].tools }; + }, + ); + await check( + 'read-only policy denies workspace mutation under YOLO', + async () => { + const f = await fixture('readonly'); + const file = path.join(f.workspace, 'denied'); + const run = await invoke(f, { filesystem: 'read-only' }, [ + { command: `printf forbidden > ${quote(file)}` }, + ]); + pipelineOkay(run); + assert.equal(await exists(file), false); + assert.match( + JSON.stringify(run.requests), + /Read-only file system|Permission denied/, + ); + return { fixture: f.dir, writeDenied: true }; + }, + ); + await check( + 'second runtime writes only its own admitted workspace', + async () => { + const f = await fixture('second'); + const first = path.join(root, 'main/workspace/from-second'); + const own = path.join(f.workspace, 'own'); + const run = await invoke(f, {}, [ + { + command: `printf own > ${quote(own)}; printf forbidden > ${quote(first)}`, + }, + ]); + pipelineOkay(run); + assert.equal(await read(own), 'own'); + assert.equal(await exists(first), false); + return { fixture: f.dir, ownAllowed: true, firstDenied: true }; + }, + ); + await check( + 'two Config instances in one process retain separate grants', + async () => { + const a = await fixture('shared-a'); + const b = await fixture('shared-b'); + const ownA = path.join(a.workspace, 'own'); + const ownB = path.join(b.workspace, 'own'); + const deniedA = path.join(a.workspace, 'written-by-b'); + const deniedB = path.join(b.workspace, 'written-by-a'); + const fileA = path.join(a.workspace, 'file-own-a.txt'); + const fileB = path.join(b.workspace, 'file-own-b.txt'); + const crossFileA = path.join(a.workspace, 'file-from-b.txt'); + const crossFileB = path.join(b.workspace, 'file-from-a.txt'); + scenarios.set(b.name, { + steps: [ + { + command: `printf own-b > ${quote(ownB)}; printf forbidden > ${quote(deniedA)}`, + }, + { name: 'write_file', args: { file_path: fileB, content: 'file-b' } }, + { + name: 'write_file', + args: { file_path: crossFileA, content: 'escaped' }, + }, + ], + }); + const run = await invoke( + a, + { second: { workspace: b.workspace, baseUrl: b.baseUrl } }, + [ + { + command: `printf own-a > ${quote(ownA)}; printf forbidden > ${quote(deniedB)}`, + }, + { name: 'write_file', args: { file_path: fileA, content: 'file-a' } }, + { + name: 'write_file', + args: { file_path: crossFileB, content: 'escaped' }, + }, + ], + ); + pipelineOkay(run); + const bRequests = allRequests.filter((r) => r.scenario === b.name); + assert.ok(bRequests.length >= 2); + assert.deepEqual([...new Set(bRequests[0].tools)].sort(), [ + 'agent', + 'edit', + 'glob', + 'list_directory', + 'monitor', + 'read_file', + 'run_shell_command', + 'task_stop', + 'write_file', + ]); + assert.equal(run.summary.runtimes.length, 2); + assert.notEqual( + run.summary.runtimes[0].sessionId, + run.summary.runtimes[1].sessionId, + ); + assert.equal(await read(ownA), 'own-a'); + assert.equal(await read(ownB), 'own-b'); + assert.equal(await exists(deniedA), false); + assert.equal(await exists(deniedB), false); + assert.equal(await read(fileA), 'file-a'); + assert.equal(await read(fileB), 'file-b'); + assert.equal(await exists(crossFileA), false); + assert.equal(await exists(crossFileB), false); + return { + fixtures: [a.dir, b.dir], + sessionIds: run.summary.runtimes.map((r) => r.sessionId), + bothOwnWritesAllowed: true, + bothCrossWritesDenied: true, + }; + }, + ); + await check( + 'bare startup ignores ambient hooks discovery and MCP', + async () => { + const f = await fixture('ambient'); + const marker = path.join(f.outside, 'must-not-exist'); + const markerCommand = `printf escaped > ${quote(marker)}`; + const hooks = { + PreToolUse: [ + { + matcher: '*', + hooks: [{ type: 'command', command: markerCommand }], + }, + ], + SessionStart: [ + { hooks: [{ type: 'command', command: markerCommand }] }, + ], + }; + const run = await invoke( + f, + { + settings: { + hooks, + tools: { + discoveryCommand: markerCommand, + callCommand: markerCommand, + }, + mcpServers: { + probe: { command: '/bin/sh', args: ['-c', markerCommand] }, + }, + }, + hooks: { userHooks: hooks, projectHooks: hooks }, + }, + [{ command: 'printf SAFE_BARE_STARTUP' }], + ); + pipelineOkay(run); + assert.equal(await exists(marker), false); + assert.match(JSON.stringify(run.requests), /SAFE_BARE_STARTUP/); + return { + fixture: f.dir, + ambientMarkersAbsent: true, + tools: run.requests[0].tools, + }; + }, + ); + for (const entrypoint of ['mcp', 'extensions', 'lsp', 'acp']) { + await check( + `explicit unsupported startup is rejected: ${entrypoint}`, + async () => { + const f = await fixture(`startup-${entrypoint}`); + const marker = path.join(f.outside, 'must-not-exist'); + const command = `printf escaped > ${quote(marker)}`; + const argv = + entrypoint === 'mcp' + ? { + mcpConfig: JSON.stringify({ + mcpServers: { + probe: { command: '/bin/sh', args: ['-c', command] }, + }, + }), + } + : entrypoint === 'extensions' + ? { extensions: ['sandbox-test-extension'] } + : entrypoint === 'lsp' + ? { experimentalLsp: true } + : { acp: true }; + const run = await invoke(f, { argv, startupOnly: true }); + assert.notEqual(run.code, 0, 'Unsupported startup must fail closed'); + assert.match( + run.stderr + JSON.stringify(run.summary), + /sandbox.*(requires|does not.*support)/i, + ); + assert.equal(await exists(marker), false); + assert.equal( + run.requests.length, + 0, + 'Startup rejection precedes model work', + ); + return { fixture: f.dir, code: run.code, markerAbsent: true }; + }, + ); + } + await check( + 'setup failure never falls back or replays unconfined', + async () => { + const f = await fixture('setup'); + const file = path.join(f.workspace, 'must-not-exist'); + const run = await invoke(f, { bwrapPath: '/usr/bin/false' }, [ + { command: `printf forbidden > ${quote(file)}` }, + ]); + assert.notEqual(run.code, 0); + assert.equal(run.signal, null); + assert.equal( + run.requests.length, + 0, + 'Probe rejection precedes model work', + ); + assert.equal(await exists(file), false); + assert.match( + JSON.stringify(run.summary), + /Sandbox capability probe failed/i, + ); + return { fixture: f.dir, sideEffectAbsent: true, startupRejected: true }; + }, + ); + await check( + 'production Read Write Edit preserve bytes modes and read cache', + async () => { + const f = await fixture('file-semantics'); + const existing = path.join(f.workspace, 'existing.txt'); + const wide = path.join(f.workspace, 'wide.txt'); + const created = path.join(f.workspace, 'nested/new.txt'); + const editedNew = path.join(f.workspace, 'edit-created.txt'); + await fs.writeFile(existing, Buffer.from('\ufeffalpha\r\nbeta\r\n')); + await fs.chmod(existing, 0o751); + await fs.writeFile( + wide, + Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from('wide-before\r\n', 'utf16le'), + ]), + ); + await fs.chmod(wide, 0o600); + const run = await invoke(f, {}, [ + { name: 'read_file', args: { file_path: existing } }, + { + name: 'edit', + args: { + file_path: existing, + old_string: 'alpha', + new_string: 'ALPHA', + }, + }, + { + name: 'write_file', + args: { file_path: existing, content: 'ALPHA\nbeta-write\n' }, + }, + { name: 'read_file', args: { file_path: wide } }, + { + name: 'edit', + args: { + file_path: wide, + old_string: 'wide-before', + new_string: 'wide-after', + }, + }, + { + name: 'write_file', + args: { + file_path: created, + content: '中文🙂 first\nrepeat repeat\n', + }, + }, + { + name: 'edit', + args: { + file_path: created, + old_string: 'repeat', + new_string: 'changed', + replace_all: true, + }, + }, + { + name: 'edit', + args: { + file_path: editedNew, + old_string: '', + new_string: 'new-file\n', + }, + }, + ]); + pipelineOkay(run); + assert.deepEqual( + await fs.readFile(existing), + Buffer.from('\ufeffALPHA\r\nbeta-write\r\n'), + ); + assert.equal((await fs.stat(existing)).mode & 0o777, 0o751); + assert.deepEqual( + await fs.readFile(wide), + Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from('wide-after\r\n', 'utf16le'), + ]), + ); + assert.equal((await fs.stat(wide)).mode & 0o777, 0o600); + assert.equal(await read(created), '中文🙂 first\nchanged changed\n'); + assert.equal(await read(editedNew), 'new-file\n'); + return { + fixture: f.dir, + utf8BomCrlf: true, + utf16leBomCrlf: true, + modes: ['0751', '0600'], + newFileEditWithoutRead: true, + replaceAll: true, + }; + }, + ); + await check( + 'file writes follow allowed relative and dangling symlinks', + async () => { + const f = await fixture('file-links'); + const target = path.join(f.workspace, 'target.txt'); + const link = path.join(f.workspace, 'link.txt'); + const dangling = path.join(f.workspace, 'dangling.txt'); + await fs.writeFile(target, 'before\n'); + await fs.chmod(target, 0o600); + await fs.symlink('target.txt', link); + await fs.symlink('new-target.txt', dangling); + const run = await invoke(f, {}, [ + { name: 'read_file', args: { file_path: link } }, + { + name: 'edit', + args: { file_path: link, old_string: 'before', new_string: 'after' }, + }, + { + name: 'write_file', + args: { file_path: dangling, content: 'created-through-link\n' }, + }, + ]); + pipelineOkay(run); + assert.equal(await read(target), 'after\n'); + assert.equal((await fs.stat(target)).mode & 0o777, 0o600); + assert.equal(await fs.readlink(link), 'target.txt'); + assert.equal(await fs.readlink(dangling), 'new-target.txt'); + assert.equal( + await read(path.join(f.workspace, 'new-target.txt')), + 'created-through-link\n', + ); + return { + fixture: f.dir, + relativeLinkPreserved: true, + danglingLinkPreserved: true, + }; + }, + ); + await check( + 'file tools deny outside direct parent and leaf symlink writes without litter', + async () => { + const f = await fixture('file-outside'); + const existing = path.join(f.outside, 'existing.txt'); + const parent = path.join(f.workspace, 'outside-parent'); + const leaf = path.join(f.workspace, 'outside-leaf.txt'); + await fs.writeFile(existing, 'outside-before\n'); + await fs.symlink(f.outside, parent); + await fs.symlink(existing, leaf); + const run = await invoke(f, {}, [ + { + name: 'write_file', + args: { + file_path: path.join(f.outside, 'direct/missing.txt'), + content: 'denied', + }, + }, + { + name: 'write_file', + args: { + file_path: path.join(parent, 'nested/missing.txt'), + content: 'denied', + }, + }, + { name: 'read_file', args: { file_path: leaf } }, + { + name: 'edit', + args: { + file_path: leaf, + old_string: 'outside-before', + new_string: 'escaped', + }, + }, + { name: 'write_file', args: { file_path: leaf, content: 'escaped' } }, + ]); + pipelineOkay(run); + assert.equal(await read(existing), 'outside-before\n'); + assert.deepEqual((await fs.readdir(f.outside)).sort(), ['existing.txt']); + assert.equal(await fs.readlink(parent), f.outside); + assert.equal(await fs.readlink(leaf), existing); + assert.match( + JSON.stringify(run.requests), + /read.only|permission denied|EACCES|EROFS|EPERM/i, + ); + return { + fixture: f.dir, + outsideUnchanged: true, + noOutsideMkdirOrTemp: true, + linksPreserved: true, + }; + }, + ); + await check( + 'file prior-read enforcement rejects unseen and stale content', + async () => { + const f = await fixture('file-prior-read'); + const unseenEdit = path.join(f.workspace, 'unseen-edit.txt'); + const unseenWrite = path.join(f.workspace, 'unseen-write.txt'); + const stale = path.join(f.workspace, 'stale.txt'); + for (const p of [unseenEdit, unseenWrite, stale]) + await fs.writeFile(p, 'before\n'); + const run = await invoke(f, {}, [ + { + name: 'edit', + args: { + file_path: unseenEdit, + old_string: 'before', + new_string: 'escaped', + }, + }, + { + name: 'write_file', + args: { file_path: unseenWrite, content: 'escaped' }, + }, + { name: 'read_file', args: { file_path: stale } }, + { + name: 'edit', + args: { + file_path: stale, + old_string: 'before', + new_string: 'escaped', + }, + before: () => fs.writeFile(stale, 'host-changed-longer\n'), + }, + ]); + pipelineOkay(run); + assert.equal(await read(unseenEdit), 'before\n'); + assert.equal(await read(unseenWrite), 'before\n'); + assert.equal(await read(stale), 'host-changed-longer\n'); + assert.match( + JSON.stringify(run.requests), + /read.*before|not.*read|prior.read/i, + ); + assert.match(JSON.stringify(run.requests), /changed|modified/i); + return { + fixture: f.dir, + unseenWritesDenied: true, + staleEditDenied: true, + }; + }, + ); + await check('read-only policy denies Write and Edit under YOLO', async () => { + const f = await fixture('file-readonly'); + const existing = path.join(f.workspace, 'existing.txt'); + const fresh = path.join(f.workspace, 'nested/new.txt'); + await fs.writeFile(existing, 'before\n'); + const run = await invoke(f, { filesystem: 'read-only' }, [ + { name: 'read_file', args: { file_path: existing } }, + { + name: 'edit', + args: { + file_path: existing, + old_string: 'before', + new_string: 'escaped', + }, + }, + { name: 'write_file', args: { file_path: fresh, content: 'escaped' } }, + ]); + pipelineOkay(run); + assert.equal(await read(existing), 'before\n'); + assert.equal(await exists(path.dirname(fresh)), false); + assert.match( + JSON.stringify(run.requests), + /read.only|permission denied|EACCES|EROFS|EPERM/i, + ); + return { fixture: f.dir, existingUnchanged: true, noNewDirectories: true }; + }); + await check( + 'file worker transport accepts content larger than one MiB', + async () => { + const f = await fixture('file-large'); + const file = path.join(f.workspace, 'large.txt'); + const content = '中文🙂'.repeat(110000) + '\n'; + assert.ok(Buffer.byteLength(content) > 1024 * 1024); + const run = await invoke(f, {}, [ + { name: 'write_file', args: { file_path: file, content } }, + ]); + pipelineOkay(run); + assert.equal(await read(file), content); + return { + fixture: f.dir, + bytes: Buffer.byteLength(content), + sha256: await hash(file), + }; + }, + ); + await check('PDF Read is rejected before host helper execution', async () => { + const f = await fixture('file-pdf'); + const file = path.join(f.workspace, 'document.pdf'); + const marker = path.join(f.outside, 'pdf-helper'); + f.helperPath = path.join(f.dir, 'helpers'); + await fs.mkdir(f.helperPath); + for (const name of ['pdfinfo', 'pdftotext', 'pdftoppm']) { + const helper = path.join(f.helperPath, name); + await fs.writeFile( + helper, + `#!/bin/sh\nprintf ${name} >> ${quote(marker)}\nexit 1\n`, + { mode: 0o755 }, + ); + } + await fs.writeFile(file, '%PDF-1.4\nfixture\n'); + const run = await invoke(f, {}, [ + { name: 'read_file', args: { file_path: file, pages: '1' } }, + ]); + pipelineOkay(run); + assert.equal(await exists(marker), false); + assert.match( + JSON.stringify(run.requests), + /PDF.*sandbox|sandbox.*PDF|PDF.*not.*supported/i, + ); + return { fixture: f.dir, noHostPdfHelpers: true }; + }); + await check( + 'Write and Edit reject special files without blocking', + async () => { + const f = await fixture('file-special'); + const fifo = path.join(f.workspace, 'fifo'); + execFileSync('/usr/bin/mkfifo', [fifo]); + const run = await invoke(f, {}, [ + { name: 'write_file', args: { file_path: fifo, content: 'denied' } }, + { + name: 'edit', + args: { file_path: fifo, old_string: '', new_string: 'denied' }, + }, + ]); + pipelineOkay(run); + assert.equal((await fs.stat(fifo)).isFIFO(), true); + assert.match( + JSON.stringify(run.requests.at(-1).toolResults), + /regular file|special file/i, + ); + return { + fixture: f.dir, + fifoUnchanged: true, + completedWithoutReader: true, + }; + }, + ); + await check( + 'file setup failure never falls back to host mutation', + async () => { + const f = await fixture('file-setup'); + const file = path.join(f.workspace, 'nested/denied.txt'); + const run = await invoke(f, { bwrapPath: '/usr/bin/false' }, [ + { name: 'write_file', args: { file_path: file, content: 'escaped' } }, + ]); + assert.notEqual(run.code, 0); + assert.equal(run.signal, null); + assert.equal( + run.requests.length, + 0, + 'Probe rejection precedes model work', + ); + assert.equal(await exists(path.dirname(file)), false); + assert.match( + JSON.stringify(run.summary), + /Sandbox capability probe failed/i, + ); + return { + fixture: f.dir, + noHostMkdirOrWrite: true, + startupRejected: true, + }; + }, + ); + for (const mode of [ + 'background-ok', + 'background-fail', + 'promoted', + 'cancel', + 'timeout', + 'task-stop', + 'lost', + 'background-lost', + 'promoted-lost', + ]) { + await check(`production ShellTool lifecycle: ${mode}`, async () => + controlled(await fixture(mode), mode), + ); + } +} finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + const actualHashes = {}; + await check('artifact integrity after verification', async () => { + const artifactErrors = []; + const expectedFiles = new Set([ + ...Object.keys(manifest.artifacts), + 'manifest.json', + 'node_modules', + ]); + for (const name of await fs.readdir(installation)) { + if (!expectedFiles.has(name)) artifactErrors.push(`${name}: unexpected`); + } + for (const name of Object.keys(manifest.artifacts)) { + try { + actualHashes[name] = await hash(path.join(installation, name)); + } catch (error) { + artifactErrors.push(`${name}: ${error.message}`); + } + } + for (const [name, expected] of Object.entries(manifest.artifacts)) { + if (actualHashes[name] !== expected) { + artifactErrors.push(`${name}: hash differs from the candidate build`); + } + } + assert.deepEqual(artifactErrors, []); + return { actualHashes }; + }); + let bwrap; + try { + bwrap = execFileSync('/usr/bin/bwrap', ['--version'], { + encoding: 'utf8', + }).trim(); + } catch (error) { + bwrap = `unavailable: ${error.code ?? error.message}`; + } + await fs.writeFile( + output, + JSON.stringify( + { + time: new Date().toISOString(), + root, + installation, + hostNs, + node: process.version, + kernel: execFileSync('uname', ['-r'], { encoding: 'utf8' }).trim(), + bwrap, + uid, + manifestSha256, + source, + expectedArtifactHashes: manifest.artifacts, + actualHashes, + results, + requests: allRequests, + }, + null, + 2, + ), + ); +} +console.log( + JSON.stringify({ + root, + passed: results.filter((r) => r.passed).length, + failed: results.filter((r) => !r.passed).length, + output, + }), +); +process.exitCode = results.every((r) => r.passed) ? 0 : 1; diff --git a/scripts/tests/sandbox-runtime-source-manifest.test.js b/scripts/tests/sandbox-runtime-source-manifest.test.js new file mode 100644 index 00000000000..68142e1f443 --- /dev/null +++ b/scripts/tests/sandbox-runtime-source-manifest.test.js @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + hashFile, + readSourceIdentity, + verifySourceManifest, +} from '../sandbox-runtime/source-manifest.mjs'; + +const roots = []; + +async function fixture() { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-source-manifest-'), + ); + roots.push(root); + await fs.writeFile(path.join(root, 'input.txt'), 'before\n'); + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['config', 'core.hooksPath', os.devNull], { cwd: root }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: root }); + execFileSync('git', ['config', 'status.showUntrackedFiles', 'normal'], { + cwd: root, + }); + execFileSync('git', ['add', 'input.txt'], { cwd: root }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Qwen Test', + '-c', + 'user.email=qwen-test@example.com', + 'commit', + '-qm', + 'fixture', + ], + { cwd: root }, + ); + const identity = readSourceIdentity(root); + return { + root, + manifest: { + ...identity, + inputs: { 'input.txt': await hashFile(path.join(root, 'input.txt')) }, + }, + }; +} + +describe('sandbox runtime source manifest', () => { + afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); + }); + + it('records untracked files despite ambient Git status configuration', async () => { + const { root } = await fixture(); + const globalConfig = path.join(root, 'ambient-gitconfig'); + await fs.writeFile(globalConfig, '[status]\n\tshowUntrackedFiles = no\n'); + vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig); + await fs.writeFile(path.join(root, 'untracked.txt'), 'new\n'); + expect(readSourceIdentity(root).dirty).toBe(true); + }); + + it('uses the declared SHA-256 byte encoding', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-source-hash-')); + roots.push(root); + const file = path.join(root, 'known.txt'); + await fs.writeFile(file, 'known bytes\n'); + await expect(hashFile(file)).resolves.toBe( + '43697ca3ebe6227d6017713ca8449d76dc37602e0d5742e8728875c5a876cd5e', + ); + }); + + it('binds a candidate to its exact source revision and inputs', async () => { + const { root, manifest } = await fixture(); + await expect(verifySourceManifest(root, manifest)).resolves.toEqual( + manifest, + ); + }); + + it('rejects source changes after the candidate build', async () => { + const { root, manifest } = await fixture(); + await fs.writeFile(path.join(root, 'input.txt'), 'after\n'); + await expect(verifySourceManifest(root, manifest)).rejects.toThrow( + 'Source inputs differ from the candidate build', + ); + }); + + it('rejects a different source revision with matching inputs', async () => { + const { root, manifest } = await fixture(); + execFileSync( + 'git', + [ + '-c', + 'user.name=Qwen Test', + '-c', + 'user.email=qwen-test@example.com', + 'commit', + '--allow-empty', + '-qm', + 'next', + ], + { cwd: root }, + ); + await expect(verifySourceManifest(root, manifest)).rejects.toThrow( + 'Source revision differs from the candidate build', + ); + }); + + it('rejects a different source dirtiness state', async () => { + const { root, manifest } = await fixture(); + await fs.writeFile(path.join(root, 'untracked.txt'), 'new\n'); + await expect(verifySourceManifest(root, manifest)).rejects.toThrow( + 'Source worktree dirtiness differs from the candidate build', + ); + }); + + it('rejects source inputs that escape the checkout', async () => { + const { root, manifest } = await fixture(); + await expect( + verifySourceManifest(root, { + ...manifest, + inputs: { '../outside': '0'.repeat(64) }, + }), + ).rejects.toThrow('escapes the source root'); + }); + + it('rejects absolute source inputs', async () => { + const { root, manifest } = await fixture(); + await expect( + verifySourceManifest(root, { + ...manifest, + inputs: { [path.join(root, 'input.txt')]: '0'.repeat(64) }, + }), + ).rejects.toThrow('must be relative'); + }); + + it('reports a missing declared source input', async () => { + const { root, manifest } = await fixture(); + await expect( + verifySourceManifest(root, { + ...manifest, + inputs: { 'missing.txt': '0'.repeat(64) }, + }), + ).rejects.toThrow('Source manifest input is unavailable: missing.txt'); + }); + + it('reports revision drift before stale input paths', async () => { + const { root, manifest } = await fixture(); + await expect( + verifySourceManifest(root, { + ...manifest, + revision: '0'.repeat(40), + inputs: { 'missing.txt': '0'.repeat(64) }, + }), + ).rejects.toThrow('Source revision differs from the candidate build'); + }); +});