diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 9efabd0c72f..89844b9bd39 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -66,6 +66,7 @@ 11206 sync-release-to-oss.yml 2676 tui-parity.yml 7187 update-ecs-runner-qwen.yml +6206 verify-landlock-helper.yml 2307 web-shell-visuals-cleanup.yml 20047 web-shell-visuals-publish.yml 17127 web-shell-visuals.yml diff --git a/.github/workflows/verify-landlock-helper.yml b/.github/workflows/verify-landlock-helper.yml new file mode 100644 index 00000000000..e356ade7a15 --- /dev/null +++ b/.github/workflows/verify-landlock-helper.yml @@ -0,0 +1,157 @@ +name: 'Verify Landlock helper' + +on: + pull_request: + paths: + - '.github/workflows/verify-landlock-helper.yml' + - 'packages/core/vendor/landlock-run/**' + - 'scripts/build_landlock_run.mjs' + push: + branches: + - 'main' + paths: + - '.github/workflows/verify-landlock-helper.yml' + - 'packages/core/vendor/landlock-run/**' + - 'scripts/build_landlock_run.mjs' + workflow_dispatch: + +permissions: + contents: 'read' + +jobs: + verify: + name: '${{ matrix.arch }}' + strategy: + fail-fast: false + matrix: + include: + - arch: 'x64' + runner: 'ubuntu-24.04' + zig_target: 'x86_64' + zig_sha256: '70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00' + - arch: 'arm64' + runner: 'ubuntu-24.04-arm' + zig_target: 'aarch64' + zig_sha256: 'ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17' + runs-on: '${{ matrix.runner }}' + timeout-minutes: 10 + steps: + - uses: 'actions/checkout@v4' + + - name: 'Install pinned Zig compiler' + env: + ZIG_SHA256: '${{ matrix.zig_sha256 }}' + ZIG_TARGET: '${{ matrix.zig_target }}' + ZIG_VERSION: '0.16.0' + run: |- + archive="zig-${ZIG_TARGET}-linux-${ZIG_VERSION}.tar.xz" + curl --fail --location --silent --show-error --output "${RUNNER_TEMP}/${archive}" "https://ziglang.org/download/${ZIG_VERSION}/${archive}" + echo "${ZIG_SHA256} ${RUNNER_TEMP}/${archive}" | sha256sum --check --strict + tar --extract --xz --file "${RUNNER_TEMP}/${archive}" --directory "${RUNNER_TEMP}" + echo "${RUNNER_TEMP}/zig-${ZIG_TARGET}-linux-${ZIG_VERSION}" >> "${GITHUB_PATH}" + + - name: 'Rebuild committed helper' + env: + CC: 'zig' + run: |- + committed="packages/core/vendor/landlock-run/${{ matrix.arch }}-linux/qwen-landlock-run" + cp "${committed}" "${RUNNER_TEMP}/committed-helper" + node scripts/build_landlock_run.mjs '${{ matrix.arch }}' + cmp "${RUNNER_TEMP}/committed-helper" "${committed}" + file "${committed}" + + - name: 'Verify kernel enforcement and inheritance' + env: + ARCH: '${{ matrix.arch }}' + run: |- + helper="packages/core/vendor/landlock-run/${ARCH}-linux/qwen-landlock-run" + root="$(mktemp -d)" + trap 'rm -rf "${root}"' EXIT + workspace="${root}/workspace" + scratch="${root}/scratch" + outside="${root}/outside" + mkdir -p "${workspace}" "${scratch}" "${outside}" + printf 'unchanged\n' > "${outside}/marker" + + "${helper}" --probe | grep -E '^\{"abi":([3-9]|[1-9][0-9]+),"enforcement":"partial"\}$' + # shellcheck disable=SC2016 + "${helper}" --ro / --rw /dev/null --rw "${scratch}" --rw "${workspace}" -- /bin/sh -c 'printf allowed > "$1"' sh "${workspace}/created" + test "$(cat "${workspace}/created")" = 'allowed' + + # shellcheck disable=SC2016 + if "${helper}" --ro / --rw /dev/null --rw "${scratch}" --rw "${workspace}" -- /bin/sh -c 'printf denied > "$1"' sh "${outside}/marker"; then + echo 'outside write unexpectedly succeeded' >&2 + exit 1 + fi + test "$(cat "${outside}/marker")" = 'unchanged' + + # shellcheck disable=SC2016 + if "${helper}" --ro / --rw /dev/null --rw "${scratch}" -- /bin/sh -c 'printf denied > "$1"' sh "${workspace}/read-only"; then + echo 'read-only workspace write unexpectedly succeeded' >&2 + exit 1 + fi + + cat > "${workspace}/descendant.sh" <<'EOF' + #!/bin/sh + printf denied > "$1" + EOF + chmod 755 "${workspace}/descendant.sh" + # shellcheck disable=SC2016 + if "${helper}" --ro / --rw /dev/null --rw "${scratch}" --rw "${workspace}" -- /bin/sh -c '"$1" "$2"' sh "${workspace}/descendant.sh" "${outside}/descendant"; then + echo 'descendant outside write unexpectedly succeeded' >&2 + exit 1 + fi + test ! -e "${outside}/descendant" + + set +e + diagnostic="$(${helper} --ro / -- /does/not/exist 2>&1)" + status=$? + set -e + test "${status}" -eq 125 + case "${diagnostic}" in + qwen-landlock-run:\ exec\ failed:*) ;; + *) echo "unexpected launcher diagnostic: ${diagnostic}" >&2; exit 1 ;; + esac + + status_wire="${root}/status-wire" + "${helper}" --status-fd 3 --ro / -- /bin/true 3> "${status_wire}" + grep -E '^\{"state":"prepared","abi":([3-9]|[1-9][0-9]+)\}$' "${status_wire}" + set +e + "${helper}" --status-fd 3 --ro / -- /does/not/exist 3> "${status_wire}" 2> /dev/null + status=$? + set -e + test "${status}" -eq 125 + test "$(grep -c '^{' "${status_wire}")" -eq 2 + tail -n 1 "${status_wire}" | grep -E '^\{"state":"exec-failed","abi":([3-9]|[1-9][0-9]+)\}$' + + - name: 'Verify inherited nonblocking standard streams' + env: + ARCH: '${{ matrix.arch }}' + run: |- + python3 - <<'PYTHON' + import os + import subprocess + import sys + + helper = f"packages/core/vendor/landlock-run/{os.environ['ARCH']}-linux/qwen-landlock-run" + def nonblocking_stdio(): + for fd in range(3): + os.set_blocking(fd, False) + + program = """ + import os, sys + assert all(os.get_blocking(fd) for fd in range(3)) + data = sys.stdin.buffer.read() + sys.stdout.buffer.write(data) + sys.stderr.buffer.write(data) + """ + payload = bytes(range(256)) * 4096 + result = subprocess.run( + [helper, '--ro', '/', '--', sys.executable, '-c', program], + input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + preexec_fn=nonblocking_stdio, timeout=30, + ) + assert result.returncode == 0, (result.returncode, result.stderr[-500:]) + assert result.stdout == payload + assert result.stderr == payload + PYTHON diff --git a/docs/design/2026-09-20-landlock-execution-backend.md b/docs/design/2026-09-20-landlock-execution-backend.md new file mode 100644 index 00000000000..1ae184d1897 --- /dev/null +++ b/docs/design/2026-09-20-landlock-execution-backend.md @@ -0,0 +1,76 @@ +# Landlock tool execution backend + +[English](2026-09-20-landlock-execution-backend.md) | [简体中文](2026-09-20-landlock-execution-backend.zh-CN.md) + +## Status and scope + +This design adds a Landlock filesystem backend to the tool execution boundary introduced by the bwrap delivery. It builds on the public bwrap CLI cutover and trusted review-state migration already merged into main (#12267 and #12491), without restoring whole-CLI sandboxing. Shell commands, terminal shell entrypoints, and the existing file worker continue to enter confinement per tool invocation while the CLI, model transport, authentication, and session state remain on the host. + +The first Landlock release is a fallback for Linux hosts where bwrap is missing or unusable. It confines pathname content and directory-structure mutations, but Linux Landlock still cannot restrict every metadata operation such as `chmod`, `chown`, extended attributes, or timestamps. It also does not provide bwrap's PID or network namespaces. The backend therefore reports `partial` filesystem enforcement, requires Landlock ABI 3 or newer so cross-directory rename and truncation are governed, and supports only `network: open`. It never claims that a partial Landlock profile is equivalent to bwrap. + +## Operator contract + +`tools.executionSandbox.backend` accepts `auto`, `bwrap`, or `landlock`. + +- `bwrap` probes and uses only bwrap. Failure stops startup. +- `landlock` probes and uses only the bundled Landlock helper. `network: closed` and every remaining nonempty `maskedPaths` list are rejected before probing the helper or starting a payload. +- `auto` probes bwrap first. If bwrap is unusable and the requested policy has `network: open`, it probes Landlock and selects it when enforceable. A remaining nonempty mask list makes Landlock incompatible; fallback never drops masks. With `network: closed`, bwrap is the only compatible candidate; failure reports that Landlock cannot satisfy the network policy. + +Backend selection happens once during runtime initialization. The resolved backend, enforcement level, and Landlock ABI are frozen into the active Config policy. A command-time setup failure never falls through to another backend and never replays the payload on the host. + +The Landlock policy probe executes `/usr/bin/true` inside the boundary. Hosts without that executable fail the probe; helper capability alone does not make such a host usable. + +Inspection and UI surfaces show requested and effective backends. Landlock is rendered with `partial` and its probed ABI. The documentation states the uncovered metadata and process/IPC boundaries. This visibility is required because `auto` may move from full bwrap enforcement to partial Landlock enforcement on a host where user namespaces or mounts are unavailable. + +The model prompt uses the effective backend: Landlock describes partial content-write restrictions and EACCES refusals, including metadata and namespace limits; bwrap retains its read-only mount guidance. + +## Bundled helper + +Qwen Code ships `qwen-landlock-run` for Linux x64 and arm64 under `packages/core/vendor/landlock-run/-linux/`. The Apache-2.0 C11 source uses the stable raw Landlock UAPI and has no runtime library dependency because release binaries are statically linked with musl. + +Before probing or launching the bundled helper, the host restores its mode to 0755 if packaging removed all executable bits. This applies to fresh and managed installs without a postinstall script. A read-only or permission-restricted install that cannot restore the mode fails before the payload starts; it never substitutes a host command. Explicit helper-path overrides are not chmod'ed. Helper lookup distinguishes source layout from the bundle directory by its trailing path components, so installation ancestor names do not change asset resolution. + +The command contract is: + +```text +qwen-landlock-run --probe +qwen-landlock-run [--status-fd ] [--ro ]... [--rw ]... -- ... +``` + +`--ro` grants read and execute rights. `--rw` grants every filesystem right handled by the negotiated ABI. All handled rights outside those roots are denied. File grants mask directory-only rights. Every missing or unopenable grant, unsupported ABI, ruleset error, or failed exec exits 125 with a `qwen-landlock-run: ` diagnostic and does not silently remove a requested grant. + +The helper requires ABI 3. ABI 1 cannot grant cross-directory reparenting and ABI 2 cannot control truncation, so accepting those kernels would leave common content-write paths outside the stated policy. ABI 5's device-ioctl right is enabled when available. Newer kernel rights are not treated as full coverage; the backend remains partial until the product contract and helper are reviewed together. + +Both relays initialize Node's shared output descriptors before spawning the helper or bwrap, preventing lazy stream setup from changing their flags after the child starts. The Landlock helper then restores blocking mode on open standard streams before exec, so inherited Node relay flags cannot truncate native payload output with `EAGAIN`. It sets `PR_SET_NO_NEW_PRIVS`, installs the ruleset, sets a parent-death signal, and then `exec`s the payload. Landlock restrictions survive exec and are inherited by descendants. The helper sends a small execution attestation to a dedicated status file descriptor; an unrestricted relay turns that wire plus the child status into the same confirmed/unconfirmed receipt used by bwrap. This preserves the no-blind-retry rule and temporary-directory retention behavior. + +## Filesystem profile + +Both backends retain broad host reads. Landlock grants read and execute below `/`, write access to `/dev/null` and the per-command scratch directory, and write access to the canonical workspace only for `workspace-write`. Runtime state and installation roots remain read-only. The helper opens rule roots with `O_PATH`, so rules bind to filesystem objects rather than trusting a later textual path lookup. + +Landlock does not create bwrap's private `/dev` and `/proc` mounts. Its narrower write grants reject opening other device paths such as `/dev/full` or `/dev/tty` for writing and creating files in `/dev/shm`; programs that require these operations may fail. Use the private scratch directory exposed through `TMPDIR`, `TMP`, and `TEMP` for temporary files. This restriction concerns new pathname access, not the caller-provided standard streams that are already open. + +Trusted review state remains outside the writable workspace under the merged migration. Landlock cannot hide paths under its broad read grant: both its probe and execution entrypoints reject every nonempty `maskedPaths` list. Bubblewrap continues to enforce explicit masks. + +The unrestricted Node relay starts with a minimal environment. Payload variables travel through an exclusively created, mode-0600 control file, which the relay reads and removes before spawning the static helper. Variables such as `NODE_OPTIONS` therefore apply only after confinement to a Node payload, never to the unrestricted relay. The Landlock process receives the same sanitized payload environment as bwrap, with `TMPDIR`, `TMP`, and `TEMP` pointing at private scratch. The existing workspace/protected-root admission checks remain authoritative. File writes continue through the same worker and version check; shell and file paths dispatch through the runtime's resolved backend. + +Landlock does not create a PID namespace. Host processes remain visible, while Landlock's domain comparison restricts ptrace-like access to less-confined processes. Pathname and abstract Unix sockets are not part of this first helper profile. `network: open` therefore retains host networking and reachable host services. Closed networking remains a bwrap-only promise until a separately reviewed seccomp or namespace design can cover the whole network vocabulary. + +## Packaging and verification + +The build script compiles one architecture at a time with a native `musl-gcc` or an explicit Zig musl target. The two committed binaries are copied by the existing core `vendor/` package and bundle paths. A dedicated Linux x64/arm64 workflow rebuilds the helper, compares it byte-for-byte with the committed binary, runs the functional probe, and checks read-only denial, workspace write allowance, outside-write denial, descendant inheritance, and launcher failure attribution. + +Production-bundle acceptance covers read-only and workspace-write policies, network and mask refusal, literal argv and payload environment isolation, byte-exact stdin/stdout/stderr, streamed-input backpressure, slow consumers, EPIPE cleanup, negative controls, and no unconfined replay. Unit coverage exercises settings validation, exact backend selection order, incompatible-network refusal, probe parsing, asset resolution, helper argv, backend dispatch, status-wire parsing, diagnostics, and public `qwen sandbox` verification differences. Real Linux acceptance must run the exact committed helper on both architectures. macOS tests can validate selection and argv construction but do not establish kernel enforcement. + +## Security and compatibility + +- Selection is fail-closed. Neither probe failure nor launch failure produces an unconfined retry. +- `partial` is an enforcement fact, not a warning label that callers may reinterpret as full. It covers Landlock's documented gaps in metadata operations and the absence of namespace isolation. +- ABI 3 is the minimum. Kernels without enabled Landlock or with older ABIs are unusable. +- Network-closed policies and nonempty path-mask policies never select Landlock. +- Open descriptors retain their pre-Landlock rights. The relay streams host-backed regular-file and named-FIFO stdin through a pipe; it only directly shares sockets, character devices, or anonymous pipes. It preserves input bytes and backpressure without granting the payload a host-file stdin descriptor. Output standard streams remain explicit caller-provided destinations. The helper marks its execution-status descriptor close-on-exec. +- The helper's parent-death signal terminates the direct payload if its relay dies. Landlock has no PID-namespace lifetime boundary: detached descendants keep the inherited filesystem restrictions but rely on the existing runtime process cleanup rather than `--die-with-parent` semantics. +- Host reads, process visibility, and host Unix-socket reachability remain outside the first Landlock promise and are shown in documentation and verification output. + +## Follow-up work + +A future seccomp companion may make closed networking available without bwrap, but it requires its own syscall and compatibility design. Landlock's newer IPC scopes and ABI 9 pathname Unix-socket resolution, along with future filesystem rights, should be adopted only with exact-kernel tests and a deliberate enforcement-level decision. This PR does not change ACP/serve support, broaden writable roots, add per-command approval-derived policies, or change the whole-CLI Docker/Podman/Seatbelt paths. diff --git a/docs/design/2026-09-20-landlock-execution-backend.zh-CN.md b/docs/design/2026-09-20-landlock-execution-backend.zh-CN.md new file mode 100644 index 00000000000..351a968e233 --- /dev/null +++ b/docs/design/2026-09-20-landlock-execution-backend.zh-CN.md @@ -0,0 +1,76 @@ +# Landlock 工具执行后端 + +[English](2026-09-20-landlock-execution-backend.md) | [简体中文](2026-09-20-landlock-execution-backend.zh-CN.md) + +## 状态与范围 + +本设计在 bwrap 交付建立的工具执行边界中加入 Landlock 文件系统后端。它基于已经合并到 main 的公开 bwrap CLI 切换和可信 review 状态迁移(#12267、#12491),不恢复整 CLI 沙箱。Shell 命令、终端 Shell 入口和现有文件 worker 仍按每次工具调用进入隔离,CLI、模型传输、认证和会话状态继续运行在宿主机上。 + +首个 Landlock 版本用于 Linux 主机上 bwrap 缺失或不可用时的回退。它约束路径内容和目录结构变更,但 Linux Landlock 目前仍不能限制 `chmod`、`chown`、扩展属性或时间戳等所有元数据操作,也不提供 bwrap 的 PID 或网络 namespace。因此该后端报告 `partial` 文件系统执行完整性,要求 Landlock ABI 3 或更高版本以覆盖跨目录重命名和截断,并且只支持 `network: open`。它不会宣称部分执行的 Landlock profile 与 bwrap 等价。 + +## 操作员契约 + +`tools.executionSandbox.backend` 接受 `auto`、`bwrap` 或 `landlock`。 + +- `bwrap` 只探测和使用 bwrap,失败时停止启动。 +- `landlock` 只探测和使用随包发布的 Landlock helper;`network: closed` 和所有剩余非空 `maskedPaths` 列表都会在 helper 探测或 payload 启动前被拒绝。 +- `auto` 首先探测 bwrap。如果 bwrap 不可用且请求策略为 `network: open`,则探测 Landlock,并在内核可执行时选择它。剩余非空 mask 列表意味着 Landlock 不兼容;回退绝不会丢弃 mask。对于 `network: closed`,bwrap 是唯一兼容候选;失败信息会说明 Landlock 无法满足网络策略。 + +后端选择在 runtime 初始化期间只执行一次。解析后的后端、执行完整性和 Landlock ABI 会冻结到活动 Config 策略中。命令执行阶段的设置失败不会再切换到另一个后端,也不会在宿主机上重放 payload。 + +Landlock 策略探测会在边界内执行 `/usr/bin/true`。缺少该可执行文件的主机会探测失败;仅有 helper 能力并不意味着该主机可用。 + +检查命令和 UI 会同时展示请求后端与实际后端。Landlock 会显示 `partial` 和已探测的 ABI。文档明确列出未覆盖的元数据及进程/IPC 边界。因为 `auto` 可能在用户 namespace 或 mount 不可用的主机上从完整的 bwrap 执行切换到部分 Landlock 执行,这种可见性是必要条件。 + +模型提示词使用实际生效的后端:Landlock 描述 partial 内容写入限制和 EACCES 拒绝,并说明元数据与 namespace 限制;bwrap 保留只读挂载指引。 + +## 随包 helper + +Qwen Code 在 `packages/core/vendor/landlock-run/-linux/` 中为 Linux x64 和 arm64 发布 `qwen-landlock-run`。Apache-2.0 的 C11 源码直接使用稳定的 Landlock 原始 UAPI;发布二进制使用 musl 静态链接,因此没有运行时库依赖。 + +在探测或启动内置 helper 前,如果打包移除了全部执行权限位,宿主会将其权限恢复为 0755。这适用于全新安装和托管更新,无需 postinstall 脚本。只读或权限受限的安装若无法恢复权限,会在 payload 启动前失败,绝不替换为宿主命令。显式 helper 路径覆盖不会被 chmod。Helper 查找通过目录末尾的路径分量区分源码布局与 bundle 目录,因此安装位置的祖先目录名不会改变资源解析。 + +命令契约为: + +```text +qwen-landlock-run --probe +qwen-landlock-run [--status-fd ] [--ro ]... [--rw ]... -- ... +``` + +`--ro` 授予读取和执行权限;`--rw` 授予协商 ABI 能处理的全部文件系统权限。已处理权限在这些根目录之外默认拒绝。文件 grant 会去除仅适用于目录的权限。任何缺失或无法打开的 grant、不支持的 ABI、ruleset 错误或 exec 失败都会以 125 退出,并输出带 `qwen-landlock-run: ` 前缀的诊断;helper 不会静默移除请求的 grant。 + +helper 要求 ABI 3。ABI 1 无法授予跨目录重新挂接,ABI 2 无法控制截断;接受这些内核会使常见内容写入路径脱离声明的策略。ABI 5 提供的设备 ioctl 权限会在可用时启用。更新内核新增的权限不会被当作完整覆盖;在产品契约和 helper 一起复核前,该后端始终保持 partial。 + +两个 relay 均在启动 helper 或 bwrap 前初始化 Node 的共享输出描述符,防止延迟的流初始化在子进程启动后改变其标志。随后,Landlock helper 在 exec 前恢复已打开标准流的阻塞模式,防止继承的 Node relay 标志使原生 payload 输出因 `EAGAIN` 被截断。它设置 `PR_SET_NO_NEW_PRIVS`、安装 ruleset、设置父进程退出信号,然后 `exec` payload。Landlock 限制会跨 exec 保留并由后代继承。helper 通过专用状态文件描述符发送小型执行回执;不受限的 relay 将该回执和子进程状态转换为 bwrap 使用的同一种 confirmed/unconfirmed 回执,从而保留禁止盲目重试及诊断临时目录保留行为。 + +## 文件系统 profile + +两个后端都保留广泛的宿主读取能力。Landlock 对 `/` 以下授予读取和执行权限,对 `/dev/null` 与每条命令的私有 scratch 目录授予写权限;仅在 `workspace-write` 下对规范化 workspace 授予写权限。runtime 状态和安装目录保持只读。helper 使用 `O_PATH` 打开规则根目录,因此规则绑定到文件系统对象,而不是信任后续文本路径查找。 + +Landlock 不创建 bwrap 的私有 `/dev` 和 `/proc` 挂载。其更窄的写授权会拒绝以写入方式打开 `/dev/full`、`/dev/tty` 等其他设备路径,以及在 `/dev/shm` 中创建文件;依赖这些操作的程序可能失败。临时文件应使用 `TMPDIR`、`TMP` 和 `TEMP` 指向的私有 scratch 目录。这项限制针对新的路径访问,不针对调用方提供的已打开标准流。 + +可信 review 状态按已合并的迁移规则保留在可写 workspace 之外。Landlock 的广泛读取授权无法隐藏路径,因此其 probe 和执行入口都拒绝所有非空 `maskedPaths` 列表。Bubblewrap 继续执行显式 mask。 + +不受限的 Node relay 使用最小环境启动。Payload 变量通过独占创建、权限为 0600 的控制文件传递,relay 在启动静态 helper 前读取并删除该文件。因此 `NODE_OPTIONS` 等变量只会作用于隔离后的 Node payload,不会作用于不受限的 relay。Landlock 进程接收与 bwrap 相同的净化 payload 环境,其中 `TMPDIR`、`TMP` 和 `TEMP` 指向私有 scratch。现有 workspace/受保护根目录准入检查继续作为权威。文件写入仍通过同一个 worker 和版本检查;Shell 与文件路径通过 runtime 已解析的后端分发。 + +Landlock 不创建 PID namespace。宿主进程仍可见,而 Landlock 的 domain 比较会限制对权限更宽进程的 ptrace 类访问。首个 helper profile 不包含路径名或抽象 Unix socket 限制。因此 `network: open` 保留宿主网络和可达的宿主服务。在单独评审的 seccomp 或 namespace 设计能够覆盖完整网络语义之前,关闭网络仍是仅 bwrap 提供的承诺。 + +## 打包与验证 + +构建脚本使用原生 `musl-gcc` 或显式 Zig musl target,每次编译一个架构。两个提交的二进制由现有 core `vendor/` 包和 bundle 路径复制。专用 Linux x64/arm64 workflow 会重新构建 helper,与提交二进制逐字节比较,运行功能探测,并检查只读拒绝、workspace 写入允许、外部写入拒绝、后代继承及 launcher 失败归因。 + +生产 bundle 验收覆盖只读与 workspace-write 策略、网络及 mask 拒绝、字面 argv 和 payload 环境隔离、stdin/stdout/stderr 逐字节传输、流式输入背压、慢速消费者、EPIPE 清理、负向控制及禁止非隔离重放。单元测试覆盖设置校验、精确后端选择顺序、不兼容网络拒绝、probe 解析、资源解析、helper argv、后端分发、状态 wire 解析、诊断以及公开 `qwen sandbox` 的验证差异。真实 Linux 验收必须在两个架构上运行精确提交的 helper。macOS 测试可以验证选择与 argv 构造,但不能证明内核执行。 + +## 安全与兼容性 + +- 选择流程 fail-closed。probe 失败或启动失败都不会产生非隔离重试。 +- `partial` 是执行事实,不能被调用方重新解释为 full 的普通警告。它覆盖 Landlock 文档明确的元数据操作缺口和缺少 namespace 隔离。 +- 最低要求 ABI 3。未启用 Landlock 或 ABI 更旧的内核不可用。 +- 网络关闭策略和非空路径 mask 策略绝不选择 Landlock。 +- 已打开的描述符保留进入 Landlock 前的权限。Relay 将宿主普通文件和命名 FIFO 的 stdin 通过管道流式转发;仅直接共享 socket、字符设备或匿名管道。在保留输入字节和背压的同时,不向 payload 授予宿主文件 stdin 描述符。输出标准流仍是调用方显式提供的目标。Helper 将执行状态描述符标记为 close-on-exec。 +- 如果 relay 退出,helper 的父进程退出信号会终止直接 payload。Landlock 不提供 PID namespace 生命周期边界:脱离的后代继续继承文件系统限制,但其清理由现有 runtime 进程清理机制负责,而不具备 `--die-with-parent` 语义。 +- 宿主读取、进程可见性和宿主 Unix socket 可达性不属于首个 Landlock 承诺,并会在文档和验证输出中展示。 + +## 后续工作 + +未来可用 seccomp 伴随层在没有 bwrap 时提供关闭网络,但它需要独立的 syscall 与兼容性设计。Landlock 较新的 IPC scope、ABI 9 路径名 Unix socket 解析及未来文件系统权限,只能在精确内核测试及明确执行完整性决策后采用。本 PR 不改变 ACP/serve 支持,不扩大可写根目录,不加入由每次审批派生的策略,也不改变整 CLI Docker/Podman/Seatbelt 路径。 diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index b17ee4060af..514fa86819a 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -11,7 +11,7 @@ ## Tool execution sandbox -`tools.executionSandbox` is an operator-only Linux policy with required `filesystem: "read-only" | "workspace-write"` and `network: "open" | "closed"`, plus optional `backend: "auto" | "bwrap"`. System policy replaces the complete User/SystemDefaults object; Workspace values are ignored. Bare/safe modes retain the policy and changing it requires restart. See [Sandbox](../features/sandbox.md#linux-tool-execution-sandbox) for configuration, scope, unsupported integrations, verification and migration from whole-CLI bwrap. +`tools.executionSandbox` is an operator-only Linux policy with required `filesystem: "read-only" | "workspace-write"` and `network: "open" | "closed"`, plus optional `backend: "auto" | "bwrap" | "landlock"`. `auto` prefers bwrap and can fall back to partial Landlock filesystem enforcement only when command networking is open. System policy replaces the complete User/SystemDefaults object; Workspace values are ignored. Bare/safe modes retain the policy and changing it requires restart. See [Sandbox](../features/sandbox.md#linux-tool-execution-sandbox) for configuration, scope, unsupported integrations, verification and migration from whole-CLI bwrap. ## Configuration layers diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index c2ae91dd851..1ce5407d6a2 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -4,7 +4,7 @@ This document explains tool execution confinement on Linux and the existing whol ## Linux tool execution sandbox -On Linux, install `bwrap` (Bubblewrap) and enable the tool execution boundary in your **User** settings (`~/.qwen/settings.json`, or the directory selected by `QWEN_HOME`) or administrator **System** settings: +On Linux, install `bwrap` (Bubblewrap) for the strongest supported boundary, then enable the tool execution sandbox in your **User** settings (`~/.qwen/settings.json`, or the directory selected by `QWEN_HOME`) or administrator **System** settings: ```json { @@ -18,9 +18,13 @@ On Linux, install `bwrap` (Bubblewrap) and enable the tool execution boundary in } ``` -`filesystem` and `network` are required. `backend` defaults to `auto`; both `auto` and `bwrap` currently select bwrap. Landlock is not available in this release. Use `read-only` to deny workspace writes, or `workspace-write` to permit writes inside the current canonical workspace. Each command also gets private scratch space. Ordinary `includeDirectories` settings do not grant write access. Keep the workspace separate from the Qwen installation, user configuration and runtime state directories. +`filesystem` and `network` are required. `backend` defaults to `auto`, which tries bwrap first. When bwrap is unavailable and `network` is `open`, `auto` can use the bundled Landlock helper on kernels with Landlock ABI 3 or newer. Set `backend` to `bwrap` or `landlock` to require that exact implementation. Landlock cannot enforce `network: closed` or hide explicit masked paths. Either requirement rejects Landlock before any command starts; automatic fallback never discards a remaining path mask. Use `read-only` to deny workspace writes, or `workspace-write` to permit writes inside the current canonical workspace. Each command also gets private scratch space. Ordinary `includeDirectories` settings do not grant write access. Keep the workspace separate from the Qwen installation, user configuration and runtime state directories. -The CLI, model transport, authentication and session storage remain on the host. Shell, terminal `!`, prompt shell interpolation, Monitor, Read/Write/Edit and supported nested in-process Agent/Code Mode calls use the same runtime policy. `network: closed` blocks ordinary host/external IP connections from commands, while `open` shares the host network. Reads remain broad: this mode does not hide host secrets or promise to isolate pathname Unix sockets and host services. Approval and YOLO do not expand the filesystem or network policy. +The CLI, model transport, authentication and session storage remain on the host. Shell, terminal `!`, prompt shell interpolation, Monitor, Read/Write/Edit and supported nested in-process Agent/Code Mode calls use the same runtime policy. With bwrap, `network: closed` blocks ordinary host/external IP connections from commands, while `open` shares the host network. Reads remain broad: this mode does not hide host secrets or promise to isolate pathname Unix sockets and host services. Approval and YOLO do not expand the filesystem or network policy. + +Landlock reports `partial` enforcement because it restricts pathname reads, writes and directory mutations but does not create PID or network namespaces and cannot currently restrict every metadata operation, including `chmod`, `chown`, extended attributes and timestamps. A Landlock command can see host processes and uses host networking. Detached descendants retain the Landlock filesystem restrictions but do not get bwrap's PID-namespace lifetime boundary. Open file descriptors retain their existing access. Qwen Code streams host-backed file and named-FIFO stdin through a pipe instead of passing those descriptors to the payload; sockets, character devices and anonymous pipes can be shared. Output standard streams remain the caller's explicit destinations. The execution-status descriptor closes on payload exec. + +Landlock also has a narrower writable device surface: it grants writes to `/dev/null`, but does not create bwrap's private `/dev` and `/proc` mounts. Opening `/dev/full` or `/dev/tty` for writing and creating files in `/dev/shm` are denied, so programs requiring these operations may fail. Use the private scratch directory exposed through `TMPDIR`, `TMP`, and `TEMP` for temporary files. Already-open, caller-provided standard streams keep their existing access. Startup's Landlock policy probe requires `/usr/bin/true`; a host missing it fails the probe even if its kernel supports Landlock. Workspace settings cannot enable, disable or modify this policy, even in a trusted project. Effective precedence is System over User over SystemDefaults, selecting a complete policy object. Values must be literals; environment substitutions, unknown fields and incomplete objects are rejected. `--bare` and `--safe-mode` retain operator confinement. Changes require a new runtime. A malformed operator settings file fails startup instead of resetting an unknown sandbox policy to empty settings. @@ -37,7 +41,7 @@ qwen sandbox -- sh -c 'printf "confined command\n"' qwen -y -p "Update the project and run its tests" ``` -The report names the tool boundary, requested/effective backend, workspace, filesystem and command network policy. `--verify` checks workspace writes, denial against a file known to be writable on the host, private PID namespace identity and the selected network namespace. Backend setup errors fail before payload execution and never rerun the command on the host. The one-command subcommand forwards literal arguments and redirected stdin while preserving stdout and stderr byte-for-byte; use terminal `!` for interactive PTY commands. +The report names the tool boundary, requested/effective backend, enforcement level, Landlock ABI when applicable, workspace, filesystem and command network policy. `--verify` checks workspace writes, denial against a file known to be writable on the host, the expected private or shared PID namespace identity, and the selected network namespace. Backend setup errors fail before payload execution and never rerun the command on the host. The one-command subcommand forwards literal arguments and redirected stdin while preserving stdout and stderr byte-for-byte; use terminal `!` for interactive PTY commands. ### Migrating from whole-CLI bwrap diff --git a/esbuild.config.js b/esbuild.config.js index 05496a4dd8b..f7f0aba6a4a 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -298,6 +298,7 @@ const codeModeHostBuild = esbuild.build({ const sandboxWorkersBuild = esbuild.build({ entryPoints: { sandboxBwrapRelay: 'packages/core/src/sandbox/bwrap-relay.ts', + sandboxLandlockRelay: 'packages/core/src/sandbox/landlock-relay.ts', sandboxFileWorker: 'packages/core/src/sandbox/file-worker.ts', }, bundle: true, diff --git a/packages/cli/src/commands/sandbox.test.ts b/packages/cli/src/commands/sandbox.test.ts index d82a6917277..b58fd72044b 100644 --- a/packages/cli/src/commands/sandbox.test.ts +++ b/packages/cli/src/commands/sandbox.test.ts @@ -36,8 +36,8 @@ vi.mock('@qwen-code/qwen-code-core/sandbox/runtime-shell-policy.js', () => ({ params.shellExecutionSandbox, probeShellSandbox: mocks.probe, })); -vi.mock('@qwen-code/qwen-code-core/sandbox/bwrap-execution.js', () => ({ - executeBwrap: mocks.execute, +vi.mock('@qwen-code/qwen-code-core/sandbox/execute-sandbox.js', () => ({ + executeSandbox: mocks.execute, })); vi.mock('../utils/stdioHelpers.js', () => ({ writeStdoutLine: mocks.stdout, @@ -70,7 +70,11 @@ beforeEach(() => { mocks.settings.mockReturnValue({ merged: configured }); mocks.minimal.mockReturnValue({ merged: configured }); mocks.legacy.mockResolvedValue(undefined); - mocks.probe.mockResolvedValue(undefined); + mocks.probe.mockImplementation(async (policy: object) => ({ + ...policy, + effectiveBackend: 'bwrap', + enforcement: 'full', + })); mocks.execute.mockResolvedValue({ result: Promise.resolve({ exitCode: 42, error: null, aborted: false }), }); @@ -85,7 +89,7 @@ describe('qwen sandbox tool boundary', () => { it('reports actual scope and probes without running a user payload', async () => { await run(); expect(mocks.stdout.mock.calls.flat().join('\n')).toContain( - 'Boundary: tools; backend: auto → bwrap', + 'Boundary: tools; backend: auto → bwrap (full)', ); expect(mocks.stdout.mock.calls.flat().join('\n')).toContain( 'traffic stay on the host', diff --git a/packages/cli/src/commands/sandbox.ts b/packages/cli/src/commands/sandbox.ts index a46f489389e..58b8601a5b6 100644 --- a/packages/cli/src/commands/sandbox.ts +++ b/packages/cli/src/commands/sandbox.ts @@ -92,8 +92,8 @@ export const sandboxCommand: CommandModule = { const { admitShellSandbox, probeShellSandbox } = await import( '@qwen-code/qwen-code-core/sandbox/runtime-shell-policy.js' ); - const { executeBwrap } = await import( - '@qwen-code/qwen-code-core/sandbox/bwrap-execution.js' + const { executeSandbox } = await import( + '@qwen-code/qwen-code-core/sandbox/execute-sandbox.js' ); const { sanitizeChildEnv } = await import( '@qwen-code/qwen-code-core/utils/sanitize-child-env.js' @@ -103,7 +103,7 @@ export const sandboxCommand: CommandModule = { process.cwd(), ); const candidate = createExecutionSandboxPolicy(selected, process.cwd()); - const policy = admitShellSandbox( + const admitted = admitShellSandbox( { model: '', debugMode: false, @@ -114,14 +114,16 @@ export const sandboxCommand: CommandModule = { Storage.getRuntimeBaseDir(), Storage.getGlobalQwenDir(), )!; - report(`Boundary: tools; backend: ${policy.requestedBackend} → bwrap`); + const policy = await probeShellSandbox(admitted, controller.signal); + report( + `Boundary: tools; backend: ${policy.requestedBackend} → ${policy.effectiveBackend} (${policy.enforcement}${policy.landlockAbi ? `, ABI ${policy.landlockAbi}` : ''})`, + ); report( `Filesystem: ${policy.filesystem}; workspace: ${policy.workspace}`, ); report(`Command network: ${policy.network}`); report('Model, authentication and session traffic stay on the host.'); report('Host reads and pathname Unix sockets remain accessible.'); - await probeShellSandbox(policy, controller.signal); report('Backend probe: passed'); const env = Object.fromEntries( Object.entries(sanitizeChildEnv(process.env)).filter( @@ -141,7 +143,7 @@ export const sandboxCommand: CommandModule = { process.stderr.on('error', handleOutputError); try { // env resolves PATH inside confinement and receives literal argv. - const handle = await executeBwrap( + const handle = await executeSandbox( policy, { executable: '/usr/bin/env', @@ -217,7 +219,7 @@ export const sandboxCommand: CommandModule = { if (inside === 'allowed') fs.unlinkSync(process.argv[1]); console.log(JSON.stringify({inside, outside:write(process.argv[2], 'w'), pid:fs.readlinkSync('/proc/self/ns/pid'), net:fs.readlinkSync('/proc/self/ns/net')})); `; - const handle = await executeBwrap( + const handle = await executeSandbox( policy, { executable: fs.realpathSync(process.execPath), @@ -239,16 +241,26 @@ export const sandboxCommand: CommandModule = { [ 'workspace policy', observed['inside'] === - (policy.filesystem === 'workspace-write' ? 'allowed' : 'EROFS'), + (policy.filesystem === 'workspace-write' + ? 'allowed' + : policy.effectiveBackend === 'landlock' + ? 'EACCES' + : 'EROFS'), ], [ 'host-writable outside file denied', - observed['outside'] === 'EROFS' && + observed['outside'] === + (policy.effectiveBackend === 'landlock' ? 'EACCES' : 'EROFS') && fs.readFileSync(outside, 'utf8') === 'unchanged', ], [ - 'private PID namespace', - typeof observed['pid'] === 'string' && observed['pid'] !== hostPid, + policy.effectiveBackend === 'landlock' + ? 'shared PID namespace (Landlock boundary)' + : 'private PID namespace', + typeof observed['pid'] === 'string' && + (policy.effectiveBackend === 'landlock' + ? observed['pid'] === hostPid + : observed['pid'] !== hostPid), ], [ 'command network namespace', diff --git a/packages/cli/src/config/execution-sandbox-config.ts b/packages/cli/src/config/execution-sandbox-config.ts index 5c3881b8555..a4d325b8515 100644 --- a/packages/cli/src/config/execution-sandbox-config.ts +++ b/packages/cli/src/config/execution-sandbox-config.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { Storage } from '@qwen-code/qwen-code-core/config/storage.js'; -import { sandboxAsset } from '@qwen-code/qwen-code-core/sandbox/bwrap-execution.js'; +import { sandboxAsset } from '@qwen-code/qwen-code-core/sandbox/sandbox-execution.js'; import type { ShellExecutionSandboxPolicy } from '@qwen-code/qwen-code-core/config/config.js'; import type { ExecutionSandboxSettings } from './execution-sandbox-settings.js'; @@ -16,7 +16,7 @@ export function createExecutionSandboxPolicy( ): ShellExecutionSandboxPolicy { if (process.platform !== 'linux') { throw new Error( - 'tools.executionSandbox currently requires Linux with bwrap.', + 'tools.executionSandbox requires Linux with a compatible bwrap or Landlock backend.', ); } return { diff --git a/packages/cli/src/config/execution-sandbox-settings.test.ts b/packages/cli/src/config/execution-sandbox-settings.test.ts index 6b7264837b5..97aa7d07455 100644 --- a/packages/cli/src/config/execution-sandbox-settings.test.ts +++ b/packages/cli/src/config/execution-sandbox-settings.test.ts @@ -64,7 +64,6 @@ describe('operator execution sandbox policy', () => { false, [], {}, - { ...restricted, backend: 'landlock' }, { ...restricted, backend: ['auto'] }, { ...restricted, network: '${MODE}' }, { ...restricted, workspace: '/' }, @@ -73,6 +72,14 @@ describe('operator execution sandbox policy', () => { 'tools.executionSandbox', ); }); + it('accepts an explicit Landlock backend for capability validation at startup', () => { + expect( + parseExecutionSandboxSettings({ + ...writable, + backend: 'landlock', + }), + ).toEqual({ ...writable, backend: 'landlock' }); + }); it('requires complete objects even in a higher priority scope', () => { expect(() => selectOperatorExecutionSandbox( diff --git a/packages/cli/src/config/execution-sandbox-settings.ts b/packages/cli/src/config/execution-sandbox-settings.ts index 0461a8a3dd9..75583336cd8 100644 --- a/packages/cli/src/config/execution-sandbox-settings.ts +++ b/packages/cli/src/config/execution-sandbox-settings.ts @@ -14,7 +14,7 @@ import { } from './storage-paths-lite.js'; export interface ExecutionSandboxSettings { - backend?: 'auto' | 'bwrap'; + backend?: 'auto' | 'bwrap' | 'landlock'; filesystem: 'read-only' | 'workspace-write'; network: 'open' | 'closed'; } @@ -45,12 +45,13 @@ export function parseExecutionSandboxSettings( !['open', 'closed'].includes(String(value.network)) || ('backend' in value && value.backend !== 'auto' && - value.backend !== 'bwrap') || + value.backend !== 'bwrap' && + value.backend !== 'landlock') || typeof value.filesystem !== 'string' || typeof value.network !== 'string' ) { throw new InvalidExecutionSandboxConfigError( - 'tools.executionSandbox requires literal filesystem (read-only | workspace-write), network (open | closed), and optional backend (auto | bwrap). Unknown fields and environment interpolation are not supported.', + 'tools.executionSandbox requires literal filesystem (read-only | workspace-write), network (open | closed), and optional backend (auto | bwrap | landlock). Unknown fields and environment interpolation are not supported.', ); } return { ...(value as ExecutionSandboxSettings) }; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 243cbab2903..4d5b4ce78f6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2829,7 +2829,10 @@ const SETTINGS_SCHEMA = { required: ['filesystem', 'network'], additionalProperties: false, properties: { - backend: { type: 'string', enum: ['auto', 'bwrap'] }, + backend: { + type: 'string', + enum: ['auto', 'bwrap', 'landlock'], + }, filesystem: { type: 'string', enum: ['read-only', 'workspace-write'], diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index bc6b1c262cb..7679550bf72 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -287,12 +287,14 @@ describe('