import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' // Relative, not `@hermes/shared`: the electron bundle is built by esbuild with // no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare // specifier would typecheck and then fail to bundle. import { clampDataUrlReadMaxMb, DATA_URL_READ_DEFAULT_MAX_MB, DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB } from '../../shared/src/data-url-read-max' const DEFAULT_FETCH_TIMEOUT_MS = 30_000 // Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach // reader so the payload still fits uvicorn's raised ws_max_size (384 MiB) // after base64 + framing. Preview stays on the Settings-configurable path. const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024 const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024 function dataUrlReadMaxBytesFromMb(maxMb) { return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024 } const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template']) const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx']) // Owner-only mode for userData files that carry credentials (the encrypted // gateway token in connection.json, and the URL/SSH fields alongside it). // connection.json was the odd one out: its two credential-bearing neighbours // under userData are already 0600 — desktop-installation.json // (desktop-installation.ts) and native-oauth-tokens.json (main.ts // `_nativeTokenStoreIo`) — while connection.json was written with no mode at // all and landed at the 0644 umask default. This makes the three consistent. const SECRET_FILE_MODE = 0o600 // The encoding tag that marks a payload as OS-encrypted. One constant because // the writer (encryptDesktopSecret, here) and the reader // (decryptDesktopSecret, in main.ts) have to agree on the exact string across // a file boundary, and the native-token store round-trips the same shape. const SAFE_STORAGE_ENCODING = 'safeStorage' interface SecretFileFs { chmodSync: typeof fs.chmodSync lstatSync: typeof fs.lstatSync renameSync: typeof fs.renameSync rmSync: typeof fs.rmSync writeFileSync: typeof fs.writeFileSync } interface SecretFileOptions { encoding?: BufferEncoding fs?: SecretFileFs platform?: string } /** * Tighten an existing credential file to owner-only (0600), returning whether * the file now has that mode. * * Exists because `fs.writeFileSync(path, data, { mode })` only applies `mode` * when it CREATES the file — rewriting an existing path silently keeps the old * bits. So a file already on disk at 0644 (every connection.json written * before this change, since that write passed no mode at all) needs an explicit * chmod; a fresh `mode:` alone would never tighten it. * * Guards match `readInstallationId` in desktop-installation.ts, which does the * same job for the sibling userData credential file: only ever chmod a regular * file we own, never a symlink and never another user's file. Without them a * symlink planted at the path would send the chmod to whatever it resolves to. * * POSIX only. Windows has no meaningful chmod (Node maps it to the read-only * bit), and userData there is already ACL'd to the user profile, so we report * success without touching the file rather than flipping it read-only and * breaking the next write. * * Never throws: a chmod can legitimately fail (read-only mount, file owned by * another user), and failing to tighten a file is not a reason to lose the * user's configured gateway. */ function tightenSecretFileMode(filePath, options: SecretFileOptions = {}) { const fsImpl = options.fs || fs const platform = options.platform || process.platform if (platform === 'win32') { return true } try { const stat = fsImpl.lstatSync(filePath) if (!stat.isFile() || stat.isSymbolicLink()) { return false } if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { return false } if ((stat.mode & 0o777) === SECRET_FILE_MODE) { return true } fsImpl.chmodSync(filePath, SECRET_FILE_MODE) return true } catch { return false } } /** * Atomically write a credential file, owner-only wherever the OS expresses * permissions as mode bits. * * On POSIX the file is owner-only from the moment it exists. On Windows this * only gets the atomic rename: `tightenSecretFileMode` no-ops there (Node maps * chmod to the read-only bit), so the file inherits the userData directory's * ACL rather than an explicit owner-only one. Tightening Windows ACLs is being * handled once, for the Python `_secure_file`, in PR #77527 — the desktop * should follow that rather than start a second ACL story here. * * The temp-then-rename dance is what makes the mode subtle: `renameSync` keeps * the TEMP file's permissions, so writing the temp at the default umask (0644) * hands those bits to the target — and a crashed earlier write can leave a * stale temp file whose existing loose bits `mode:` will not correct. Hence the * unlink of any stale temp (which also drops a planted symlink, so the write * cannot be redirected), the create-time `mode`, and the chmod before the * rename. */ function writeSecretFileAtomic(targetPath, data, options: SecretFileOptions = {}) { const fsImpl = options.fs || fs const tmp = targetPath + '.tmp' fsImpl.rmSync(tmp, { force: true }) fsImpl.writeFileSync(tmp, data, { encoding: options.encoding, mode: SECRET_FILE_MODE }) tightenSecretFileMode(tmp, options) fsImpl.renameSync(tmp, targetPath) } function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { const fallback = Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS const parsed = Number(timeoutMs) if (Number.isFinite(parsed) && parsed > 0) { return Math.round(parsed) } return fallback } function encryptDesktopSecret(value, safeStorageApi, options: { allowPlainText?: boolean } = {}) { const raw = String(value || '') if (!raw) { return null } // Opt-in escape hatch for keyring-less Linux (e.g. Hyprland/Sway with no // GNOME Keyring or KWallet): the renderer sets this once the user confirms // the plain-text storage prompt in Settings → Gateway. const allowPlainText = options?.allowPlainText === true let encryptionAvailable = false try { encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.()) } catch { encryptionAvailable = false } if (!encryptionAvailable) { // Only downgrade to plain text when the user has explicitly opted in; // decryptDesktopSecret returns the raw value for any non-'safeStorage' // encoding, so this round-trips without any decrypt-side change. if (allowPlainText) { return { encoding: 'plain', value: raw } } throw new Error( 'Secure token storage is unavailable (no OS keyring service was found), so Hermes Desktop cannot save remote gateway tokens. ' + 'Either enable an OS keyring (e.g. GNOME Keyring or KWallet providing org.freedesktop.secrets) and try again, ' + 'confirm the plain-text storage option when prompted in Settings → Gateway, ' + 'or set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment.' ) } try { return { encoding: SAFE_STORAGE_ENCODING, value: safeStorageApi.encryptString(raw).toString('base64') } } catch (error) { const detail = error instanceof Error && error.message ? ` (${error.message})` : '' throw new Error( `Failed to encrypt the remote gateway token for secure storage${detail}. ` + 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.' ) } } // Keyring-less Linux (e.g. Hyprland/Sway with no GNOME Keyring or KWallet): // `--password-store=basic` selects Electron's built-in "basic" backend, but // Electron only counts it as available once setUsePlainTextEncryption(true) is // called. The caller runs this on whenReady, before createWindow() and anything // that could touch safeStorage, so the switch takes effect for the whole run. // // Semantics are deliberately narrow: only linux, only the exact 'basic' switch // value (never 'gnome-libsecret', 'kwallet', '', etc.), and only when the // method exists (older/mocked safeStorage may lack it) and does not throw. // Anything else is a no-op. Returns true only when it actually flipped the flag, // so the caller (and tests) can distinguish "enabled" from "left untouched". // Never throws: a failure here is non-fatal — encryption simply stays // unavailable and the user can fall back to the plain-text opt-in or the // HERMES_DESKTOP_REMOTE_* env vars. function enableBasicPasswordStoreEncryption({ platform, passwordStoreSwitch, safeStorageApi }: any = {}) { if (platform !== 'linux' || passwordStoreSwitch !== 'basic') { return false } try { if (typeof safeStorageApi?.setUsePlainTextEncryption === 'function') { safeStorageApi.setUsePlainTextEncryption(true) return true } } catch { // Non-fatal: fall through and report that encryption was not enabled. } return false } // The token-persistence seam shared by the connection-config save/apply IPC // path. Given the incoming edit, decide what token block to persist: // - No incoming token: keep the existing block's token untouched (edits that // don't retype the token must not clear it). // - persistToken false (the transient test-connection path): store the raw // value as a plain block WITHOUT touching secure storage — it is never // written to disk, so there is nothing to protect. // - Otherwise: run the incoming token through the injected encryptSecret // (encryptDesktopSecret in production), forwarding the plain-text opt-in. // // The plain-text opt-in is coerced with `=== true` HERE so the strictness lives // in one place: a truthy-but-not-true value (1, 'yes', etc.) must NOT silently // enable plain-text storage. Callers pass `allowPlainText` through raw. function resolvePersistedRemoteToken({ incomingToken, persistToken, existingToken, allowPlainText, encryptSecret }: any = {}) { if (!incomingToken) { return existingToken } if (!persistToken) { return { encoding: 'plain', value: incomingToken } } return encryptSecret(incomingToken, { allowPlainText: allowPlainText === true }) } function sensitiveFileBlockReason(filePath) { const normalized = String(filePath || '') .replace(/\\/g, '/') .toLowerCase() const basename = path.basename(normalized) const ext = path.extname(basename) if (!basename) { return null } if (normalized.includes('/.ssh/')) { return 'SSH key/config files are blocked.' } if (normalized.includes('/.gnupg/')) { return 'GPG key material is blocked.' } if (normalized.endsWith('/.aws/credentials')) { return 'AWS credential files are blocked.' } if (basename === '.env') { return '.env files are blocked because they commonly contain secrets.' } if (basename.startsWith('.env.')) { const suffix = basename.slice('.env.'.length) if (!SAFE_ENV_SUFFIXES.has(suffix)) { return `${basename} is blocked because it appears to contain environment secrets.` } } if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) { return 'SSH private key files are blocked.' } if (SENSITIVE_EXTENSIONS.has(ext)) { return `${ext} key/certificate files are blocked.` } if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') { return `${basename} is blocked because it may include auth credentials.` } return null } function ipcPathError(code: any, message: string): Error & { code: any } { const error = new Error(message) as Error & { code: any } ;(error as any).code = code return error } function rejectUnsafePathSyntax(filePath, purpose = 'File read') { if (typeof filePath !== 'string') { throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`) } const raw = filePath.trim() if (!raw) { throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`) } if (raw.includes('\0')) { throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`) } const normalized = raw.replace(/\\/g, '/').toLowerCase() if ( normalized.startsWith('//?/') || normalized.startsWith('//./') || normalized.startsWith('globalroot/device/') || normalized.includes('/globalroot/device/') ) { throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`) } return raw } function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) { const purpose = String(options.purpose || 'File read') let raw = rejectUnsafePathSyntax(filePath, purpose) // Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely // arrive as `~/...`. Node's fs has no shell — without expansion the path // resolves under process.cwd() and every read "ENOENT"s forever. if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) { raw = path.join(os.homedir(), raw.slice(1)) } if (/^file:/i.test(raw)) { let resolvedPath try { const parsed = new URL(raw) if (parsed.protocol !== 'file:') { throw new Error('not a file URL') } resolvedPath = fileURLToPath(parsed) } catch { throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) } rejectUnsafePathSyntax(resolvedPath, purpose) return path.resolve(resolvedPath) } const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd() const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose) const resolvedBase = path.resolve(safeBaseInput) rejectUnsafePathSyntax(resolvedBase, purpose) const resolvedPath = path.resolve(resolvedBase, raw) rejectUnsafePathSyntax(resolvedPath, purpose) return resolvedPath } async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) { try { return await fsImpl.promises.stat(resolvedPath) } catch (error) { const code = error && typeof error === 'object' ? error.code : '' if (code === 'ENOENT' || code === 'ENOTDIR') { throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) } throw ipcPathError( code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}` ) } } async function realpathForIpc(fsImpl, resolvedPath, purpose) { if (typeof fsImpl.promises.realpath !== 'function') { return resolvedPath } try { const realPath = await fsImpl.promises.realpath(resolvedPath) rejectUnsafePathSyntax(realPath, purpose) return realPath } catch (error) { const code = error && typeof error === 'object' ? error.code : '' throw ipcPathError( code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}` ) } } function rejectSensitiveFilePath(filePath, purpose) { const blockReason = sensitiveFileBlockReason(filePath) if (blockReason) { throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) } } async function resolveDirectoryForIpc( dirPath, options: { purpose?: string baseDir?: fs.PathOrFileDescriptor fs?: { promises: { stat: typeof fs.promises.stat } } } = {} ) { const purpose = String(options.purpose || 'Directory read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory') if (!stat.isDirectory()) { throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`) } const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) return { realPath, resolvedPath, stat } } async function resolveReadableFileForIpc( filePath, options: { purpose?: string baseDir?: fs.PathOrFileDescriptor fs?: typeof fs blockSensitive?: boolean maxBytes?: number } = {} ) { const purpose = String(options.purpose || 'File read') const fsImpl = options.fs || fs const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) if (options.blockSensitive !== false) { rejectSensitiveFilePath(resolvedPath, purpose) } const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file') if (stat.isDirectory()) { throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`) } if (!stat.isFile()) { throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`) } const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) if (options.blockSensitive !== false) { rejectSensitiveFilePath(realPath, purpose) } const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null if (maxBytes && stat.size > maxBytes) { throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } try { await fsImpl.promises.access(resolvedPath, fs.constants.R_OK) } catch { throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`) } return { realPath, resolvedPath, stat } } async function readFileDataUrlForIpc( filePath, options: { purpose?: string baseDir?: fs.PathOrFileDescriptor fs?: typeof fs blockSensitive?: boolean maxBytes?: number mimeType: string } ): Promise { const fsImpl = options.fs || fs const { resolvedPath } = await resolveReadableFileForIpc(filePath, options) const data = await fsImpl.promises.readFile(resolvedPath) return `data:${options.mimeType};base64,${data.toString('base64')}` } export { ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, clampDataUrlReadMaxMb, DATA_URL_READ_DEFAULT_MAX_MB, DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB, dataUrlReadMaxBytesFromMb, DEFAULT_FETCH_TIMEOUT_MS, enableBasicPasswordStoreEncryption, encryptDesktopSecret, readFileDataUrlForIpc, rejectUnsafePathSyntax, resolveDirectoryForIpc, resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, SAFE_STORAGE_ENCODING, SECRET_FILE_MODE, sensitiveFileBlockReason, TEXT_PREVIEW_SOURCE_MAX_BYTES, tightenSecretFileMode, writeSecretFileAtomic }