From 708f9260b133c7a7cafceae0a48e6fb02d7597e9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 2 Sep 2026 13:15:51 +0200 Subject: [PATCH] fix(server): disable repository fsmonitor commands (#4208) A repository-controlled core.fsmonitor command can execute when the daemon refreshes Git state. Opening or reconciling a malicious checkout therefore allows code execution as the daemon user. Force core.fsmonitor=false on daemon-owned Git invocations before repository config is considered. --- .../src/server/session/daemon/diagnostics.ts | 2 +- .../src/utils/checkout-git-batching.test.ts | 6 +-- .../src/utils/run-git-command.posix.test.ts | 51 +++++++++++++++++++ packages/server/src/utils/run-git-command.ts | 17 ++++--- 4 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 packages/server/src/utils/run-git-command.posix.test.ts diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts index 99ca2fe61b..87ea2b2faa 100644 --- a/packages/server/src/server/session/daemon/diagnostics.ts +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -227,7 +227,7 @@ async function collectProviderEntries( async function collectToolEntries(): Promise { const [git, gh] = await Promise.all([ - checkTool("git", ["--version"]), + checkTool("git", ["-c", "core.fsmonitor=false", "--version"]), checkTool("gh", ["--version"]), ]); return [ diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 143f4f5595..85bea20c49 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -16,10 +16,10 @@ vi.mock("child_process", async () => { const [command, commandArgs] = args; if (command === "git" && Array.isArray(commandArgs)) { const normalizedArgs = commandArgs.map((arg) => String(arg)); - // `runGitCommand` always prepends `-c core.quotepath=false`; skip it to - // find the actual git subcommand. + // `runGitCommand` always prepends its two config overrides; skip them + // to find the actual git subcommand. const subcommandIndex = - normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 2 : 0; + normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 4 : 0; const isTrackedTextDiff = normalizedArgs[subcommandIndex] === "diff" && normalizedArgs.includes("HEAD") && diff --git a/packages/server/src/utils/run-git-command.posix.test.ts b/packages/server/src/utils/run-git-command.posix.test.ts new file mode 100644 index 0000000000..c336adc2fe --- /dev/null +++ b/packages/server/src/utils/run-git-command.posix.test.ts @@ -0,0 +1,51 @@ +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { runGitCommand } from "./run-git-command.js"; + +const tempDirs: string[] = []; + +function makeTempRepo(): string { + const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-fsmonitor-")); + tempDirs.push(repo); + return repo; +} + +async function configureFsmonitor(repo: string): Promise { + const hookPath = path.join(repo, "fsmonitor-hook.sh"); + const markerPath = `${hookPath}.marker`; + writeFileSync(hookPath, "#!/bin/sh\n: > \"$0.marker\"\nprintf '1\\n'\n"); + chmodSync(hookPath, 0o755); + await runGitCommand(["config", "core.fsmonitor", hookPath], { cwd: repo }); + return markerPath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +describe("runGitCommand fsmonitor isolation", () => { + it("does not execute a repository-configured fsmonitor command", async () => { + const repo = makeTempRepo(); + await runGitCommand(["init"], { cwd: repo }); + const markerPath = await configureFsmonitor(repo); + + await runGitCommand(["status", "--porcelain"], { cwd: repo }); + + expect(existsSync(markerPath)).toBe(false); + }); + + it("overrides core.fsmonitor for commands other than status", async () => { + const repo = makeTempRepo(); + await runGitCommand(["init"], { cwd: repo }); + await configureFsmonitor(repo); + + const result = await runGitCommand(["config", "--get", "core.fsmonitor"], { cwd: repo }); + + expect(result.stdout.trim()).toBe("false"); + }); +}); diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index fb8cc32c76..aca69555ee 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -377,12 +377,17 @@ function runGitCommandWithProvenance( try { // `core.quotepath=false` makes git emit raw UTF-8 paths instead of // octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`). - child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], { - cwd: options.cwd, - envOverlay, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); + // `core.fsmonitor=false` prevents repository config from launching a command. + child = spawnProcess( + "git", + ["-c", "core.quotepath=false", "-c", "core.fsmonitor=false", ...args], + { + cwd: options.cwd, + envOverlay, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + ); spawnGitCommandTrace(commandTrace, child.pid); } catch (error) { rejectSpawnFailure(error);