fix: narrow desktop file read IPC (#11149)

Signed-off-by: Jasper Hugo <jasper@spiral.xyz>
This commit is contained in:
Jasper
2026-08-12 15:30:06 -05:00
committed by GitHub
parent 526d519983
commit 62fa758b36
8 changed files with 914 additions and 82 deletions
@@ -5,7 +5,6 @@ import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { CronPicker } from './CronPicker';
import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe';
import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
import { defineMessages, useIntl } from '../../i18n';
@@ -129,15 +128,13 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
}, [isOpen, schedule, initialDeepLink, handleDeepLinkChange]);
const handleBrowseFile = async () => {
const defaultPath = getStorageDirectory(true);
const filePath = await window.electron.selectFileOrDirectory(defaultPath);
if (filePath) {
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
setRecipeSourcePath(filePath);
const fileResponse = await window.electron.selectRecipeFile();
if (fileResponse) {
if (fileResponse.filePath.endsWith('.yaml') || fileResponse.filePath.endsWith('.yml')) {
setRecipeSourcePath(fileResponse.filePath);
setInternalValidationError(null);
try {
const fileResponse = await window.electron.readFile(filePath);
if (!fileResponse.found || fileResponse.error) {
throw new Error(intl.formatMessage(i18n.failedReadFile));
}
@@ -22,6 +22,18 @@ const baseProps = {
};
describe('ScheduleModal', () => {
it('preserves the form when the recipe picker is cancelled', async () => {
const user = userEvent.setup();
const selectRecipeFile = vi.fn().mockResolvedValue(null);
window.electron.selectRecipeFile = selectRecipeFile;
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
await user.click(screen.getByRole('button', { name: 'Browse for YAML file...' }));
expect(selectRecipeFile).toHaveBeenCalledOnce();
expect(screen.queryByText(/Failed to read|Invalid file type/)).not.toBeInTheDocument();
});
it('clears a validation error from create mode when reopened to edit a schedule', async () => {
const user = userEvent.setup();
const { rerender } = renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
@@ -148,8 +148,6 @@ const FileInfo = ({ filePath, found }: { filePath: string; found: boolean }) =>
);
};
const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath);
interface GoosehintsModalProps {
directory: string;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
@@ -167,23 +165,26 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi
useEffect(() => {
const fetchGoosehintsFile = async () => {
try {
const { file, error, found } = await getGoosehintsFile(goosehintsFilePath);
const { file, error, found } = await window.electron.readGoosehints();
setGoosehintsFile(file);
setGoosehintsFileFound(found);
setGoosehintsFileReadError(found && error ? error : '');
setGoosehintsFileReadError(error ?? '');
} catch (error) {
console.error('Error fetching .goosehints file:', error);
setGoosehintsFileReadError(intl.formatMessage(i18n.failedToAccess));
}
};
if (directory) fetchGoosehintsFile();
}, [directory, goosehintsFilePath, intl]);
}, [directory, intl]);
const writeFile = async () => {
setIsSaving(true);
setSaveSuccess(false);
try {
await window.electron.writeFile(goosehintsFilePath, goosehintsFile);
const saved = await window.electron.writeGoosehints(goosehintsFile);
if (!saved) {
throw new Error('Unable to save .goosehints');
}
setSaveSuccess(true);
setGoosehintsFileFound(true);
setTimeout(() => setSaveSuccess(false), 3000);
+465
View File
@@ -0,0 +1,465 @@
import fs, { constants as fsConstants } from 'node:fs';
import fsPromises from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DesktopFileAccess,
isAppRendererUrl,
isAuthorizedFileAccessRequest,
readSelectedRecipe,
} from './desktopFileAccess';
const tempDirectories: string[] = [];
function makeTempDirectory(): string {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'goose-desktop-file-access-'));
tempDirectories.push(directory);
return directory;
}
afterEach(() => {
vi.restoreAllMocks();
while (tempDirectories.length > 0) {
fs.rmSync(tempDirectories.pop()!, { recursive: true, force: true });
}
});
describe('DesktopFileAccess', () => {
it('reads .goosehints from the bound working directory', async () => {
const workingDirectory = makeTempDirectory();
fs.writeFileSync(path.join(workingDirectory, '.goosehints'), 'project guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const canonicalWorkingDirectory = fs.realpathSync(workingDirectory);
await expect(access.readGoosehints(7)).resolves.toEqual({
file: 'project guidance',
filePath: path.join(canonicalWorkingDirectory, '.goosehints'),
error: null,
found: true,
});
});
it('preserves missing-file behavior', async () => {
const workingDirectory = makeTempDirectory();
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const canonicalWorkingDirectory = fs.realpathSync(workingDirectory);
await expect(access.readGoosehints(7)).resolves.toEqual({
file: '',
filePath: path.join(canonicalWorkingDirectory, '.goosehints'),
error: null,
found: false,
});
});
it('creates and updates .goosehints in the bound working directory', async () => {
const workingDirectory = makeTempDirectory();
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const filePath = path.join(fs.realpathSync(workingDirectory), '.goosehints');
await expect(access.writeGoosehints(7, 'first guidance')).resolves.toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('first guidance');
await expect(access.writeGoosehints(7, 'updated guidance')).resolves.toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('updated guidance');
});
it.skipIf(process.platform === 'win32')(
'rejects a canonical working directory replaced by a symlink',
async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const originalDirectory = path.join(root, 'original-project');
const replacementDirectory = path.join(root, 'replacement-project');
fs.mkdirSync(workingDirectory);
fs.mkdirSync(replacementDirectory);
fs.writeFileSync(path.join(workingDirectory, '.goosehints'), 'original guidance');
fs.writeFileSync(path.join(replacementDirectory, '.goosehints'), 'replacement guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
fs.renameSync(workingDirectory, originalDirectory);
fs.symlinkSync(replacementDirectory, workingDirectory);
const result = await access.readGoosehints(7);
await expect(access.writeGoosehints(7, 'new guidance')).resolves.toBe(false);
expect(result.found).toBe(false);
expect(result.error).toContain('working directory changed');
expect(fs.readFileSync(path.join(originalDirectory, '.goosehints'), 'utf8')).toBe(
'original guidance'
);
expect(fs.readFileSync(path.join(replacementDirectory, '.goosehints'), 'utf8')).toBe(
'replacement guidance'
);
}
);
it('rejects a canonical working directory replaced by another directory', async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const originalDirectory = path.join(root, 'original-project');
fs.mkdirSync(workingDirectory);
fs.writeFileSync(path.join(workingDirectory, '.goosehints'), 'original guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
fs.renameSync(workingDirectory, originalDirectory);
fs.mkdirSync(workingDirectory);
fs.writeFileSync(path.join(workingDirectory, '.goosehints'), 'replacement guidance');
const result = await access.readGoosehints(7);
await expect(access.writeGoosehints(7, 'new guidance')).resolves.toBe(false);
expect(result.found).toBe(false);
expect(result.error).toContain('working directory changed');
expect(fs.readFileSync(path.join(originalDirectory, '.goosehints'), 'utf8')).toBe(
'original guidance'
);
expect(fs.readFileSync(path.join(workingDirectory, '.goosehints'), 'utf8')).toBe(
'replacement guidance'
);
});
it('rejects a bound working directory that was renamed away', async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const renamedDirectory = path.join(root, 'renamed-project');
fs.mkdirSync(workingDirectory);
fs.writeFileSync(path.join(workingDirectory, '.goosehints'), 'original guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
fs.renameSync(workingDirectory, renamedDirectory);
const result = await access.readGoosehints(7);
await expect(access.writeGoosehints(7, 'new guidance')).resolves.toBe(false);
expect(result.found).toBe(false);
expect(result.error).toContain('working directory changed');
expect(fs.readFileSync(path.join(renamedDirectory, '.goosehints'), 'utf8')).toBe(
'original guidance'
);
});
it.skipIf(process.platform === 'win32')(
'rechecks the working directory before truncating an opened .goosehints',
async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const renamedDirectory = path.join(root, 'renamed-project');
const filePath = path.join(workingDirectory, '.goosehints');
fs.mkdirSync(workingDirectory);
fs.writeFileSync(filePath, 'original guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const open = fsPromises.open.bind(fsPromises);
vi.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
fs.renameSync(workingDirectory, renamedDirectory);
fs.mkdirSync(workingDirectory);
fs.linkSync(
path.join(renamedDirectory, '.goosehints'),
path.join(workingDirectory, '.goosehints')
);
return open(...args);
});
await expect(access.writeGoosehints(7, 'new guidance')).resolves.toBe(false);
expect(fs.readFileSync(path.join(renamedDirectory, '.goosehints'), 'utf8')).toBe(
'original guidance'
);
}
);
it.skipIf(process.platform === 'win32')(
'rechecks the working directory before reading an opened .goosehints',
async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const renamedDirectory = path.join(root, 'renamed-project');
const filePath = path.join(workingDirectory, '.goosehints');
fs.mkdirSync(workingDirectory);
fs.writeFileSync(filePath, 'original guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const open = fsPromises.open.bind(fsPromises);
vi.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
fs.renameSync(workingDirectory, renamedDirectory);
fs.mkdirSync(workingDirectory);
fs.linkSync(
path.join(renamedDirectory, '.goosehints'),
path.join(workingDirectory, '.goosehints')
);
return open(...args);
});
const result = await access.readGoosehints(7);
expect(result.found).toBe(false);
expect(result.file).toBe('');
expect(result.error).toContain('working directory changed');
}
);
it('rechecks the working directory before creating a missing .goosehints', async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const renamedDirectory = path.join(root, 'renamed-project');
fs.mkdirSync(workingDirectory);
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const lstat = fsPromises.lstat.bind(fsPromises);
vi.spyOn(fsPromises, 'lstat').mockImplementation(async (...args) => {
try {
return await lstat(...args);
} catch (error) {
if (path.basename(args[0].toString()) === '.goosehints') {
fs.renameSync(workingDirectory, renamedDirectory);
fs.mkdirSync(workingDirectory);
}
throw error;
}
});
const open = vi.spyOn(fsPromises, 'open');
await expect(access.writeGoosehints(7, 'new guidance')).resolves.toBe(false);
expect(open).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(workingDirectory, '.goosehints'))).toBe(false);
});
it('rejects a renderer without a bound working directory', async () => {
const access = new DesktopFileAccess();
await expect(access.readGoosehints(99)).rejects.toThrow('not authorized');
await expect(access.writeGoosehints(99, 'project guidance')).rejects.toThrow('not authorized');
});
it.skipIf(process.platform === 'win32')(
'blocks a .goosehints symlink that escapes the working directory',
async () => {
const root = makeTempDirectory();
const workingDirectory = path.join(root, 'project');
const secretPath = path.join(root, 'secret');
fs.mkdirSync(workingDirectory);
fs.writeFileSync(secretPath, 'host secret');
fs.symlinkSync('../secret', path.join(workingDirectory, '.goosehints'));
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const result = await access.readGoosehints(7);
const saved = await access.writeGoosehints(7, 'replacement');
expect(result.found).toBe(false);
expect(result.file).toBe('');
expect(result.error).toContain('symbolic link');
expect(saved).toBe(false);
expect(fs.readFileSync(secretPath, 'utf8')).toBe('host secret');
}
);
it('does not truncate a replacement file opened after validation', async () => {
const workingDirectory = makeTempDirectory();
const filePath = path.join(workingDirectory, '.goosehints');
const originalPath = path.join(workingDirectory, 'original.goosehints');
fs.writeFileSync(filePath, 'original guidance');
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const open = fsPromises.open.bind(fsPromises);
vi.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
fs.renameSync(filePath, originalPath);
fs.writeFileSync(filePath, 'replacement guidance');
return open(...args);
});
await expect(access.writeGoosehints(7, 'new guidance')).resolves.toBe(false);
expect(fs.readFileSync(filePath, 'utf8')).toBe('replacement guidance');
expect(fs.readFileSync(originalPath, 'utf8')).toBe('original guidance');
});
it.skipIf(process.platform === 'win32')(
'keeps a symlinked working directory pinned to its bind-time target',
async () => {
const root = makeTempDirectory();
const firstProject = path.join(root, 'first-project');
const secondProject = path.join(root, 'second-project');
const workingDirectory = path.join(root, 'current-project');
fs.mkdirSync(firstProject);
fs.mkdirSync(secondProject);
fs.writeFileSync(path.join(firstProject, '.goosehints'), 'first guidance');
fs.writeFileSync(path.join(secondProject, '.goosehints'), 'second guidance');
fs.symlinkSync(firstProject, workingDirectory);
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
const canonicalFirstProject = fs.realpathSync(firstProject);
fs.unlinkSync(workingDirectory);
fs.symlinkSync(secondProject, workingDirectory);
await expect(access.readGoosehints(7)).resolves.toEqual({
file: 'first guidance',
filePath: path.join(canonicalFirstProject, '.goosehints'),
error: null,
found: true,
});
await expect(access.writeGoosehints(7, 'updated first guidance')).resolves.toBe(true);
expect(fs.readFileSync(path.join(firstProject, '.goosehints'), 'utf8')).toBe(
'updated first guidance'
);
expect(fs.readFileSync(path.join(secondProject, '.goosehints'), 'utf8')).toBe(
'second guidance'
);
}
);
it.skipIf(process.platform === 'win32')(
'rejects a non-regular .goosehints target without blocking',
async () => {
const workingDirectory = makeTempDirectory();
execFileSync('mkfifo', [path.join(workingDirectory, '.goosehints')]);
const access = new DesktopFileAccess();
await access.bindWindow(7, workingDirectory);
await expect(access.writeGoosehints(7, 'project guidance')).resolves.toBe(false);
}
);
});
describe('renderer provenance', () => {
const devServerUrl = new URL('http://127.0.0.1:5173/');
it('accepts legitimate hash-routed app URLs', () => {
expect(isAppRendererUrl('http://127.0.0.1:5173/#/settings', devServerUrl)).toBe(true);
expect(isAppRendererUrl('http://127.0.0.1:5173/#/schedules?tab=active', devServerUrl)).toBe(
true
);
expect(
isAppRendererUrl(
'file:///Applications/Goose.app/Contents/Resources/renderer/main_window/index.html#/settings',
new URL('file:///Applications/Goose.app/Contents/Resources/renderer/main_window/index.html')
)
).toBe(true);
});
it('rejects sibling paths, foreign origins, and malformed URLs', () => {
expect(isAppRendererUrl('http://127.0.0.1:5173/admin#/settings', devServerUrl)).toBe(false);
expect(isAppRendererUrl('http://localhost:5173/#/settings', devServerUrl)).toBe(false);
expect(isAppRendererUrl('https://attacker.example/#/settings', devServerUrl)).toBe(false);
expect(
isAppRendererUrl(
'file://attacker/Applications/Goose.app/Contents/Resources/renderer/main_window/index.html',
new URL('file:///Applications/Goose.app/Contents/Resources/renderer/main_window/index.html')
)
).toBe(false);
expect(isAppRendererUrl('not a URL', devServerUrl)).toBe(false);
});
it('requires a registered top-level Goose window', () => {
const legitimateRequest = {
isRegisteredWindow: true,
isMainFrame: true,
rendererUrl: 'http://127.0.0.1:5173/#/settings',
};
expect(isAuthorizedFileAccessRequest(legitimateRequest, devServerUrl)).toBe(true);
expect(
isAuthorizedFileAccessRequest(
{ ...legitimateRequest, isRegisteredWindow: false },
devServerUrl
)
).toBe(false);
expect(
isAuthorizedFileAccessRequest({ ...legitimateRequest, isMainFrame: false }, devServerUrl)
).toBe(false);
});
});
describe('readSelectedRecipe', () => {
it('reads a picker-selected YAML recipe', async () => {
const directory = makeTempDirectory();
const recipePath = path.join(directory, 'recipe.yaml');
fs.writeFileSync(recipePath, 'title: Daily summary');
await expect(readSelectedRecipe(recipePath)).resolves.toEqual({
file: 'title: Daily summary',
filePath: recipePath,
error: null,
found: true,
});
});
it('does not read a selected non-recipe file', async () => {
const directory = makeTempDirectory();
const secretPath = path.join(directory, 'secret.txt');
fs.writeFileSync(secretPath, 'host secret');
const result = await readSelectedRecipe(secretPath);
expect(result.found).toBe(false);
expect(result.file).toBe('');
expect(result.error).toContain('YAML');
});
it.skipIf(process.platform === 'win32')('allows a picker-selected YAML symlink', async () => {
const directory = makeTempDirectory();
const targetPath = path.join(directory, 'target.yaml');
const recipePath = path.join(directory, 'recipe.yaml');
fs.writeFileSync(targetPath, 'title: Linked recipe');
fs.symlinkSync(targetPath, recipePath);
await expect(readSelectedRecipe(recipePath)).resolves.toEqual({
file: 'title: Linked recipe',
filePath: recipePath,
error: null,
found: true,
});
});
it.skipIf(process.platform === 'win32')(
'reads from the opened recipe when a selected symlink is retargeted',
async () => {
const directory = makeTempDirectory();
const firstTarget = path.join(directory, 'first.yaml');
const secondTarget = path.join(directory, 'second.yaml');
const recipePath = path.join(directory, 'recipe.yaml');
fs.writeFileSync(firstTarget, 'title: First recipe');
fs.writeFileSync(secondTarget, 'title: Second recipe');
fs.symlinkSync(firstTarget, recipePath);
const open = fsPromises.open.bind(fsPromises);
const openSpy = vi.spyOn(fsPromises, 'open').mockImplementationOnce(async (...args) => {
const handle = await open(...args);
fs.unlinkSync(recipePath);
fs.symlinkSync(secondTarget, recipePath);
return handle;
});
await expect(readSelectedRecipe(recipePath)).resolves.toEqual({
file: 'title: First recipe',
filePath: recipePath,
error: null,
found: true,
});
expect(openSpy).toHaveBeenCalledOnce();
}
);
it.skipIf(process.platform === 'win32')(
'rejects a picker-selected FIFO without blocking',
async () => {
const directory = makeTempDirectory();
const recipePath = path.join(directory, 'recipe.yaml');
execFileSync('mkfifo', [recipePath]);
const openSpy = vi.spyOn(fsPromises, 'open');
const result = await readSelectedRecipe(recipePath);
expect(result.found).toBe(false);
expect(result.error).toContain('not a regular file');
expect(openSpy).toHaveBeenCalledWith(
recipePath,
fsConstants.O_RDONLY | fsConstants.O_NONBLOCK
);
}
);
});
+279
View File
@@ -0,0 +1,279 @@
import fs from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import type { Stats } from 'node:fs';
import path from 'node:path';
export interface FileReadResult {
file: string;
filePath: string;
error: string | null;
found: boolean;
}
interface FileAccessRequestProvenance {
isRegisteredWindow: boolean;
isMainFrame: boolean;
rendererUrl: string;
}
export function isAppRendererUrl(rendererUrl: string, expectedUrl: URL): boolean {
try {
const actual = new URL(rendererUrl);
if (expectedUrl.protocol === 'file:') {
return (
actual.protocol === 'file:' &&
actual.host === expectedUrl.host &&
actual.pathname === expectedUrl.pathname
);
}
return actual.origin === expectedUrl.origin && actual.pathname === expectedUrl.pathname;
} catch {
return false;
}
}
export function isAuthorizedFileAccessRequest(
provenance: FileAccessRequestProvenance,
expectedUrl: URL
): boolean {
return (
provenance.isRegisteredWindow &&
provenance.isMainFrame &&
isAppRendererUrl(provenance.rendererUrl, expectedUrl)
);
}
function isMissingFile(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'ENOENT'
);
}
function missingFile(filePath: string): FileReadResult {
return { file: '', filePath, error: null, found: false };
}
function failedRead(filePath: string, message: string): FileReadResult {
return { file: '', filePath, error: message, found: false };
}
type WorkingDirectoryBinding =
| { status: 'ready'; path: string; dev: bigint; ino: bigint }
| { status: 'missing'; path: string }
| { status: 'error'; path: string };
export class DesktopFileAccess {
private readonly workingDirectories = new Map<number, WorkingDirectoryBinding>();
private bindingForWindow(windowId: number): WorkingDirectoryBinding {
const binding = this.workingDirectories.get(windowId);
if (!binding) {
throw new Error('This window is not authorized to access .goosehints');
}
return binding;
}
private async bindingMatchesDirectory(binding: WorkingDirectoryBinding): Promise<boolean> {
if (binding.status !== 'ready') {
return false;
}
try {
const metadata = await fs.lstat(binding.path, { bigint: true });
return (
metadata.isDirectory() &&
!metadata.isSymbolicLink() &&
metadata.dev === binding.dev &&
metadata.ino === binding.ino
);
} catch {
return false;
}
}
async bindWindow(windowId: number, workingDirectory: string): Promise<void> {
const resolvedPath = path.resolve(workingDirectory);
try {
const canonicalPath = await fs.realpath(resolvedPath);
const metadata = await fs.lstat(canonicalPath, { bigint: true });
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error('Working directory is not a regular directory');
}
this.workingDirectories.set(windowId, {
status: 'ready',
path: canonicalPath,
dev: metadata.dev,
ino: metadata.ino,
});
} catch (error) {
this.workingDirectories.set(windowId, {
status: isMissingFile(error) ? 'missing' : 'error',
path: resolvedPath,
});
}
}
unbindWindow(windowId: number): void {
this.workingDirectories.delete(windowId);
}
async readGoosehints(windowId: number): Promise<FileReadResult> {
const binding = this.bindingForWindow(windowId);
const filePath = path.join(binding.path, '.goosehints');
if (binding.status === 'missing') {
return missingFile(filePath);
}
if (binding.status === 'error') {
return failedRead(filePath, 'Unable to resolve the working directory');
}
if (!(await this.bindingMatchesDirectory(binding))) {
return failedRead(filePath, 'The working directory changed after it was authorized');
}
try {
const metadata = await fs.lstat(filePath);
if (metadata.isSymbolicLink()) {
return failedRead(filePath, 'Refusing to read a symbolic link as .goosehints');
}
if (!metadata.isFile()) {
return failedRead(filePath, '.goosehints is not a regular file');
}
const canonicalFilePath = await fs.realpath(filePath);
if (path.dirname(canonicalFilePath) !== binding.path) {
return failedRead(filePath, '.goosehints resolves outside the working directory');
}
const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW;
const handle = await fs.open(canonicalFilePath, fsConstants.O_RDONLY | noFollow);
try {
const openedMetadata = await handle.stat();
if (!openedMetadata.isFile()) {
return failedRead(filePath, '.goosehints is not a regular file');
}
if (openedMetadata.dev !== metadata.dev || openedMetadata.ino !== metadata.ino) {
return failedRead(filePath, '.goosehints changed while it was being opened');
}
if (!(await this.bindingMatchesDirectory(binding))) {
return failedRead(filePath, 'The working directory changed after it was authorized');
}
return {
file: await handle.readFile('utf8'),
filePath,
error: null,
found: true,
};
} finally {
await handle.close();
}
} catch (error) {
if (isMissingFile(error)) {
return missingFile(filePath);
}
return failedRead(filePath, 'Unable to read .goosehints');
}
}
async writeGoosehints(windowId: number, content: string): Promise<boolean> {
const binding = this.bindingForWindow(windowId);
if (binding.status !== 'ready' || typeof content !== 'string') {
return false;
}
if (!(await this.bindingMatchesDirectory(binding))) {
return false;
}
const filePath = path.join(binding.path, '.goosehints');
const noFollow = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW;
try {
let metadata: Stats;
try {
metadata = await fs.lstat(filePath);
} catch (error) {
if (!isMissingFile(error)) {
return false;
}
if (!(await this.bindingMatchesDirectory(binding))) {
return false;
}
const handle = await fs.open(
filePath,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
0o666
);
try {
if (!(await handle.stat()).isFile()) {
return false;
}
if (!(await this.bindingMatchesDirectory(binding))) {
return false;
}
await handle.writeFile(content, 'utf8');
return true;
} finally {
await handle.close();
}
}
if (metadata.isSymbolicLink() || !metadata.isFile()) {
return false;
}
const handle = await fs.open(filePath, fsConstants.O_WRONLY | noFollow);
try {
const openedMetadata = await handle.stat();
if (
!openedMetadata.isFile() ||
openedMetadata.dev !== metadata.dev ||
openedMetadata.ino !== metadata.ino
) {
return false;
}
if (!(await this.bindingMatchesDirectory(binding))) {
return false;
}
await handle.truncate(0);
await handle.writeFile(content, 'utf8');
return true;
} finally {
await handle.close();
}
} catch {
return false;
}
}
}
export async function readSelectedRecipe(filePath: string): Promise<FileReadResult> {
const extension = path.extname(filePath).toLowerCase();
if (extension !== '.yaml' && extension !== '.yml') {
return failedRead(filePath, 'The selected recipe must be a YAML file');
}
try {
const nonBlocking = process.platform === 'win32' ? 0 : fsConstants.O_NONBLOCK;
const handle = await fs.open(filePath, fsConstants.O_RDONLY | nonBlocking);
try {
const metadata = await handle.stat();
if (!metadata.isFile()) {
return failedRead(filePath, 'The selected recipe is not a regular file');
}
return {
file: await handle.readFile('utf8'),
filePath,
error: null,
found: true,
};
} finally {
await handle.close();
}
} catch (error) {
if (isMissingFile(error)) {
return missingFile(filePath);
}
return failedRead(filePath, 'Unable to read the selected recipe');
}
}
+98 -67
View File
@@ -1,4 +1,4 @@
import type { OpenDialogOptions, OpenDialogReturnValue } from 'electron';
import type { IpcMainInvokeEvent, OpenDialogOptions, OpenDialogReturnValue } from 'electron';
import {
app,
App,
@@ -57,6 +57,11 @@ import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-insta
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
import { buildCSP } from './utils/csp';
import { resolveWorkingDir } from './utils/workingDir';
import {
DesktopFileAccess,
isAuthorizedFileAccessRequest,
readSelectedRecipe,
} from './desktopFileAccess';
function shouldSetupUpdater(): boolean {
// Setup updater if either the flag is enabled OR dev updates are enabled
@@ -992,6 +997,27 @@ let appConfig = {
const windowMap = new Map<number, BrowserWindow>();
const appWindows = new Map<string, BrowserWindow>();
const desktopFileAccess = new DesktopFileAccess();
function requireRegularRendererWindow(event: IpcMainInvokeEvent): BrowserWindow {
const senderWindow = BrowserWindow.fromWebContents(event.sender);
const senderFrame = event.senderFrame;
if (
!senderWindow ||
!senderFrame ||
!isAuthorizedFileAccessRequest(
{
isRegisteredWindow: windowMap.get(senderWindow.id) === senderWindow,
isMainFrame: senderFrame === event.sender.mainFrame,
rendererUrl: senderFrame.url,
},
getAppUrl()
)
) {
throw new Error('This renderer is not authorized for local file access');
}
return senderWindow;
}
function getRegularWindows(): BrowserWindow[] {
return [...windowMap.values()].filter((w) => !w.isDestroyed());
@@ -1460,6 +1486,40 @@ const createChat = async (
mainWindow.show();
}
});
await desktopFileAccess.bindWindow(windowId, workingDir);
if (mainWindow.isDestroyed()) {
desktopFileAccess.unbindWindow(windowId);
return;
}
windowMap.set(windowId, mainWindow);
// Handle window closure
mainWindow.on('closed', () => {
windowMap.delete(windowId);
desktopFileAccess.unbindWindow(windowId);
pendingInitialMessages.delete(windowId);
pendingDeepLinks.delete(windowId);
reactReadyWindows.delete(windowId);
if (windowPowerSaveBlockers.has(windowId)) {
const blockerId = windowPowerSaveBlockers.get(windowId)!;
try {
powerSaveBlocker.stop(blockerId);
console.log(
`[Main] Stopped power save blocker ${blockerId} for closing window ${windowId}`
);
} catch (error) {
console.error(
`[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`,
error
);
}
windowPowerSaveBlockers.delete(windowId);
}
});
mainWindow.loadURL(formattedUrl);
// If we have an initial message, store it to send after React is ready
@@ -1508,32 +1568,6 @@ const createChat = async (
}
});
windowMap.set(windowId, mainWindow);
// Handle window closure
mainWindow.on('closed', () => {
windowMap.delete(windowId);
pendingInitialMessages.delete(windowId);
pendingDeepLinks.delete(windowId);
reactReadyWindows.delete(windowId);
if (windowPowerSaveBlockers.has(windowId)) {
const blockerId = windowPowerSaveBlockers.get(windowId)!;
try {
powerSaveBlocker.stop(blockerId);
console.log(
`[Main] Stopped power save blocker ${blockerId} for closing window ${windowId}`
);
} catch (error) {
console.error(
`[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`,
error
);
}
windowPowerSaveBlockers.delete(windowId);
}
});
return mainWindow;
};
@@ -2221,6 +2255,43 @@ ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string)
return null;
});
ipcMain.handle('select-recipe-file', async (event) => {
const senderWindow = requireRegularRendererWindow(event);
const pathRoot = appConfig.GOOSE_PATH_ROOT as string | undefined;
const recipeDirectory = pathRoot
? path.join(pathRoot, 'config', 'recipes')
: path.join(os.homedir(), '.config', 'goose', 'recipes');
let defaultPath = os.homedir();
try {
if ((await fs.stat(recipeDirectory)).isDirectory()) {
defaultPath = recipeDirectory;
}
} catch {
// The recipe directory is optional; the native picker falls back to the home directory.
}
const result = await dialog.showOpenDialog(senderWindow, {
title: 'Select a recipe',
defaultPath,
properties: ['openFile'],
filters: [{ name: 'YAML recipes', extensions: ['yaml', 'yml'] }],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return readSelectedRecipe(result.filePaths[0]);
});
ipcMain.handle('read-goosehints', async (event) => {
const senderWindow = requireRegularRendererWindow(event);
return desktopFileAccess.readGoosehints(senderWindow.id);
});
ipcMain.handle('write-goosehints', async (event, content) => {
const senderWindow = requireRegularRendererWindow(event);
return desktopFileAccess.writeGoosehints(senderWindow.id, content);
});
// Native picker tailored for session imports: shows hidden files (so users can
// reach `~/.claude/projects/...` or `~/.pi/agent/sessions/...`), filters for
// .json/.jsonl, and returns the file's contents inline so the renderer doesn't
@@ -2303,46 +2374,6 @@ ipcMain.handle('check-ollama', async () => {
}
});
ipcMain.handle('read-file', async (_event, filePath) => {
try {
const expandedPath = expandTilde(filePath);
if (process.platform === 'win32') {
const buffer = await fs.readFile(expandedPath);
return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true };
}
// Non-Windows: keep previous behavior via cat for parity
return await new Promise((resolve) => {
const cat = spawn('cat', [expandedPath]);
let output = '';
let errorOutput = '';
cat.stdout.on('data', (data) => {
output += data.toString();
});
cat.stderr.on('data', (data) => {
errorOutput += data.toString();
});
cat.on('close', (code) => {
if (code !== 0) {
resolve({ file: '', filePath: expandedPath, error: errorOutput || null, found: false });
return;
}
resolve({ file: output, filePath: expandedPath, error: null, found: true });
});
cat.on('error', (error) => {
console.error('Error reading file:', error);
resolve({ file: '', filePath: expandedPath, error, found: false });
});
});
} catch (error) {
console.error('Error reading file:', error);
return { file: '', filePath: expandTilde(filePath), error, found: false };
}
});
ipcMain.handle('write-file', async (_event, filePath, content) => {
try {
// Expand tilde to home directory
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('preload file access boundary', () => {
beforeEach(() => {
vi.resetModules();
});
it('exposes only narrow file operations without renderer-supplied paths', async () => {
const exposed: Record<string, unknown> = {};
const invoke = vi.fn();
vi.doMock('electron', () => ({
default: {},
contextBridge: {
exposeInMainWorld: (name: string, api: unknown) => {
exposed[name] = api;
},
},
ipcRenderer: {
emit: vi.fn(),
invoke,
off: vi.fn(),
on: vi.fn(),
removeListener: vi.fn(),
send: vi.fn(),
sendSync: vi.fn(),
},
webUtils: { getPathForFile: vi.fn() },
}));
await import('./preload');
const electron = exposed.electron as Record<string, (...args: unknown[]) => unknown>;
expect(electron).not.toHaveProperty('readFile');
electron.selectRecipeFile('/etc/passwd');
electron.readGoosehints('../secret');
electron.writeGoosehints('project guidance', '../secret');
expect(invoke).toHaveBeenNthCalledWith(1, 'select-recipe-file');
expect(invoke).toHaveBeenNthCalledWith(2, 'read-goosehints');
expect(invoke).toHaveBeenNthCalledWith(3, 'write-goosehints', 'project guidance');
});
});
+6 -2
View File
@@ -118,7 +118,9 @@ type ElectronAPI = {
error?: string;
} | null>;
getBinaryPath: (binaryName: string) => Promise<string>;
readFile: (directory: string) => Promise<FileResponse>;
selectRecipeFile: () => Promise<FileResponse | null>;
readGoosehints: () => Promise<FileResponse>;
writeGoosehints: (content: string) => Promise<boolean>;
writeFile: (directory: string, content: string) => Promise<boolean>;
ensureDirectory: (dirPath: string) => Promise<boolean>;
listFiles: (dirPath: string, extension?: string) => Promise<string[]>;
@@ -213,7 +215,9 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke('select-file-or-directory', defaultPath),
selectImportSessionFile: () => ipcRenderer.invoke('select-import-session-file'),
getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName),
readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath),
selectRecipeFile: () => ipcRenderer.invoke('select-recipe-file'),
readGoosehints: () => ipcRenderer.invoke('read-goosehints'),
writeGoosehints: (content: string) => ipcRenderer.invoke('write-goosehints', content),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
ensureDirectory: (dirPath: string) => ipcRenderer.invoke('ensure-directory', dirPath),