From c7f0ccf102e91355bfc6c1d8cd5fb4c70180ea2e Mon Sep 17 00:00:00 2001 From: Mark IJbema Date: Mon, 6 Jul 2026 10:40:59 +0200 Subject: [PATCH] fix(cli): close non-Linux scope-check TOCTOU by verifying fd matches validated path The read is fd-pinned, but on non-Linux the scope check realpath'd the caller's path independently of the fd, so an attacker could swap the path between open and check to validate an in-root inode while the fd pointed elsewhere. fstat the open fd and compare dev/ino against the resolved path; reject if they differ, so the inode we validate is the inode we read. --- .../opencode/src/kilocode/config/variable.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/config/variable.ts b/packages/opencode/src/kilocode/config/variable.ts index 9a2cc2d2e72..fc4f4469a95 100644 --- a/packages/opencode/src/kilocode/config/variable.ts +++ b/packages/opencode/src/kilocode/config/variable.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises" -import { realpathSync } from "node:fs" +import { realpathSync, statSync } from "node:fs" import path from "node:path" export namespace ConfigVariableGuard { @@ -29,11 +29,23 @@ export namespace ConfigVariableGuard { export async function read(filePath: string, scope?: FileScope & { token?: string }) { const file = await fs.open(filePath, "r") try { - // Resolve and validate the file the fd actually points at. On Linux /proc/self/fd pins the fd; on other - // platforms we realpath the path. Either way the subsequent read is done through the same open fd - // (file.readFile), never by re-opening the path, so the validated inode is the one we read (no TOCTOU race). + // Resolve the file the fd actually points at, then validate the scope and read through the same fd + // (file.readFile) so the validated inode is exactly the one we read. + // + // On Linux /proc/self/fd/ is the kernel's canonical path for the open fd, so realpath + read both + // follow the fd — no path is re-resolved after open. On other platforms we cannot name the fd directly, + // so we realpath the caller's path and then confirm, via fstat vs. stat on that resolved path, that it + // still refers to the same inode as the open fd. If an attacker swapped the path between open and check, + // the inodes differ and we reject rather than validating one inode while reading another. const target = process.platform === "linux" ? `/proc/self/fd/${file.fd}` : filePath const resolved = realpathSync.native(target) + if (process.platform !== "linux" && scope) { + const opened = await file.stat() + const seen = statSync(resolved) + if (opened.dev !== seen.dev || opened.ino !== seen.ino) { + throw new Error(`blocked file reference changed during read: "${scope.token ?? "{file:...}"}"`) + } + } check(resolved, scope?.token ?? "{file:...}", scope) if (/^\/proc\/.*\/environ$/.test(resolved)) throw new Error("blocked process environment reference") return await file.readFile("utf-8")