From 1b8d8e670d8bba87ce113582297e4ef7ca0266af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 22:08:51 +0000 Subject: [PATCH 1/2] fix: enforce 0o600 permissions on sensitive files by unlinking before write Unlink any existing file before writing in writeFileWithMode so the mode option is always applied on fresh file creation (Node.js ignores mode for existing files). This prevents inheriting wrong permissions on Docker bind-mount filesystems such as Synology NAS where ACLs can cause SSH key files to be created with 755 instead of 600. The fs.unlink error is only swallowed for ENOENT (file doesn't exist); other errors (e.g. EPERM) propagate as before. The chmod call is kept as an additional safety net against umask effects. A test for the new-file creation case is added alongside the existing existing-file rewrite test. Agent-Logs-Url: https://github.com/mowdep/zerobyte/sessions/2a4ab129-9668-402e-8687-d792f9b9e704 Co-authored-by: mowdep <10937987+mowdep@users.noreply.github.com> --- packages/core/src/node/__tests__/fs.test.ts | 12 ++++++++++++ packages/core/src/node/fs.ts | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/packages/core/src/node/__tests__/fs.test.ts b/packages/core/src/node/__tests__/fs.test.ts index 1aaed3d4..79b20bbd 100644 --- a/packages/core/src/node/__tests__/fs.test.ts +++ b/packages/core/src/node/__tests__/fs.test.ts @@ -16,6 +16,18 @@ afterEach(async () => { }); describe("writeFileWithMode", () => { + test("applies the requested mode when creating a new file", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-")); + tempDirectories.add(tempDirectory); + + const filePath = path.join(tempDirectory, "identity"); + + await writeFileWithMode(filePath, "content", FILE_MODES.ownerReadWrite); + + expect(await fs.readFile(filePath, "utf8")).toBe("content"); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); + test("applies the requested mode even when rewriting an existing file", async () => { const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-")); tempDirectories.add(tempDirectory); diff --git a/packages/core/src/node/fs.ts b/packages/core/src/node/fs.ts index 1b5b4b06..de8b9d1b 100644 --- a/packages/core/src/node/fs.ts +++ b/packages/core/src/node/fs.ts @@ -7,6 +7,13 @@ export const FILE_MODES = { type FileMode = (typeof FILE_MODES)[keyof typeof FILE_MODES]; export const writeFileWithMode = async (filePath: string, data: string, mode: FileMode) => { + // Remove any existing file first so the mode option on writeFile is always applied + // on a fresh file creation (mode is ignored for existing files). This also avoids + // inheriting incorrect permissions from a previously-created file on filesystems + // where chmod may not behave as expected (e.g. Docker bind-mounts on some NAS systems). + await fs.unlink(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); await fs.writeFile(filePath, data, { mode }); await fs.chmod(filePath, mode); }; From de92e7499f86dbfeb395da24fde68eaf26bd4725 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 23:03:31 +0000 Subject: [PATCH 2/2] fix: check and enforce 0o600 on SFTP key files when mount fails, then retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sshfs fails, check permissions on the private key and known_hosts files. If any have wrong permissions (not 0o600 — possible on Docker bind-mounts with ACL inheritance such as Synology NAS), apply chmod 600 and retry the mount once with a warning log. Add ensureFileMode helper to packages/core/src/node/fs.ts (exported from node index) and three tests for it: correct mode (no-op), wrong mode (fixes and returns true), non-existent file (no-op). Agent-Logs-Url: https://github.com/mowdep/zerobyte/sessions/d5edc5d0-fa7d-424b-a763-97601f577c92 Co-authored-by: mowdep <10937987+mowdep@users.noreply.github.com> --- apps/agent/src/volume-host/backends/sftp.ts | 27 ++++++++++++-- packages/core/src/node/__tests__/fs.test.ts | 40 ++++++++++++++++++++- packages/core/src/node/fs.ts | 18 ++++++++++ packages/core/src/node/index.ts | 2 +- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/agent/src/volume-host/backends/sftp.ts b/apps/agent/src/volume-host/backends/sftp.ts index 7940c67c..9d056cc9 100644 --- a/apps/agent/src/volume-host/backends/sftp.ts +++ b/apps/agent/src/volume-host/backends/sftp.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { spawn } from "node:child_process"; import type { BackendConfig } from "@zerobyte/contracts/volumes"; -import { FILE_MODES, logger, writeFileWithMode } from "@zerobyte/core/node"; +import { FILE_MODES, ensureFileMode, logger, writeFileWithMode } from "@zerobyte/core/node"; import { toMessage } from "@zerobyte/core/utils"; import { OPERATION_TIMEOUT, SSH_KEYS_DIR } from "../constants"; import { getMountForPath } from "../fs"; @@ -142,7 +142,30 @@ const mount = async (config: BackendConfig, mountPath: string) => { const args = [`${config.username}@${config.host}:${config.path || ""}`, mountPath, "-o", options.join(",")]; if (config.password) args.push("-o", "password_stdin"); logger.info(`Executing sshfs: sshfs ${args.join(" ")}`); - await runSshfs(args, config.password); + + try { + await runSshfs(args, config.password); + } catch (sshfsError) { + // On some Docker bind-mount filesystems (e.g. Synology NAS) the chmod call + // inside writeFileWithMode may not have taken effect due to ACL inheritance. + // SSH refuses to use key files whose permissions are too open, so check and + // re-apply 0o600 on all sensitive files written above, then retry once. + const [keyFixed, knownHostsFixed] = await Promise.all([ + config.privateKey + ? ensureFileMode(getPrivateKeyPath(mountPath), FILE_MODES.ownerReadWrite) + : Promise.resolve(false), + config.knownHosts + ? ensureFileMode(getKnownHostsPath(mountPath), FILE_MODES.ownerReadWrite) + : Promise.resolve(false), + ]); + + if (keyFixed || knownHostsFixed) { + logger.warn("SFTP mount failed and key file permissions were incorrect; permissions have been fixed. Retrying mount..."); + await runSshfs(args, config.password); + } else { + throw sshfsError; + } + } logger.info(`SFTP volume at ${mountPath} mounted successfully.`); return { status: "mounted" as const }; diff --git a/packages/core/src/node/__tests__/fs.test.ts b/packages/core/src/node/__tests__/fs.test.ts index 79b20bbd..7519413e 100644 --- a/packages/core/src/node/__tests__/fs.test.ts +++ b/packages/core/src/node/__tests__/fs.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { FILE_MODES, writeFileWithMode } from "../fs"; +import { FILE_MODES, ensureFileMode, writeFileWithMode } from "../fs"; const tempDirectories = new Set(); @@ -15,6 +15,44 @@ afterEach(async () => { tempDirectories.clear(); }); +describe("ensureFileMode", () => { + test("returns false and leaves the file unchanged when mode is already correct", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-")); + tempDirectories.add(tempDirectory); + + const filePath = path.join(tempDirectory, "identity"); + await fs.writeFile(filePath, "content"); + await fs.chmod(filePath, 0o600); + + const fixed = await ensureFileMode(filePath, FILE_MODES.ownerReadWrite); + + expect(fixed).toBe(false); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); + + test("returns true and applies the correct mode when permissions are wrong", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-")); + tempDirectories.add(tempDirectory); + + const filePath = path.join(tempDirectory, "identity"); + await fs.writeFile(filePath, "content"); + await fs.chmod(filePath, 0o755); + + const fixed = await ensureFileMode(filePath, FILE_MODES.ownerReadWrite); + + expect(fixed).toBe(true); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); + + test("returns false when the file does not exist", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-ensure-file-mode-")); + tempDirectories.add(tempDirectory); + + const fixed = await ensureFileMode(path.join(tempDirectory, "nonexistent.key"), FILE_MODES.ownerReadWrite); + expect(fixed).toBe(false); + }); +}); + describe("writeFileWithMode", () => { test("applies the requested mode when creating a new file", async () => { const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-")); diff --git a/packages/core/src/node/fs.ts b/packages/core/src/node/fs.ts index de8b9d1b..4b41cd7a 100644 --- a/packages/core/src/node/fs.ts +++ b/packages/core/src/node/fs.ts @@ -6,6 +6,24 @@ export const FILE_MODES = { type FileMode = (typeof FILE_MODES)[keyof typeof FILE_MODES]; +/** + * Checks whether an existing file has the expected mode and, if not, applies chmod. + * Returns true when the mode was corrected, false when the file already had the + * correct mode or does not exist. + */ +export const ensureFileMode = async (filePath: string, mode: FileMode): Promise => { + try { + const stat = await fs.stat(filePath); + if ((stat.mode & 0o777) !== mode) { + await fs.chmod(filePath, mode); + return true; + } + } catch { + // File does not exist or cannot be stat'd — nothing to fix. + } + return false; +}; + export const writeFileWithMode = async (filePath: string, data: string, mode: FileMode) => { // Remove any existing file first so the mode option on writeFile is always applied // on a fresh file creation (mode is ignored for existing files). This also avoids diff --git a/packages/core/src/node/index.ts b/packages/core/src/node/index.ts index 61f21366..3c05c729 100644 --- a/packages/core/src/node/index.ts +++ b/packages/core/src/node/index.ts @@ -2,4 +2,4 @@ export { safeSpawn, safeExec } from "./spawn.js"; export type { SafeSpawnParams, SafeSpawnParamsLines, SafeSpawnParamsRaw, SpawnResult } from "./spawn.js"; export { logger } from "./logger.js"; export { sanitizeSensitiveData } from "../utils/sanitize.js"; -export { FILE_MODES, writeFileWithMode } from "./fs.js"; +export { FILE_MODES, writeFileWithMode, ensureFileMode } from "./fs.js";