diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index d1491bd5..b579bbb1 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -706,11 +706,11 @@ export class AgentProcessManager { }; childProcess.stdout?.on('data', (data: Buffer) => { - stdoutBuffer = processBufferedOutput(stdoutBuffer, data.toString('utf8')); + stdoutBuffer = processBufferedOutput(stdoutBuffer, data.toString('utf-8')); }); childProcess.stderr?.on('data', (data: Buffer) => { - stderrBuffer = processBufferedOutput(stderrBuffer, data.toString('utf8')); + stderrBuffer = processBufferedOutput(stderrBuffer, data.toString('utf-8')); }); childProcess.on('exit', (code: number | null) => { diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index 7ff4fc23..0dcad18d 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -421,7 +421,7 @@ export class AgentQueueManager { // Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support childProcess.stdout?.on('data', (data: Buffer) => { - const log = data.toString('utf8'); + const log = data.toString('utf-8'); // Collect output for rate limit detection (keep last 10KB) allOutput = (allOutput + log).slice(-10000); @@ -509,7 +509,7 @@ export class AgentQueueManager { // Handle stderr - also emit as logs, explicitly decode as UTF-8 childProcess.stderr?.on('data', (data: Buffer) => { - const log = data.toString('utf8'); + const log = data.toString('utf-8'); // Collect stderr for rate limit detection too allOutput = (allOutput + log).slice(-10000); console.error('[Ideation STDERR]', log); @@ -757,7 +757,7 @@ export class AgentQueueManager { // Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support childProcess.stdout?.on('data', (data: Buffer) => { - const log = data.toString('utf8'); + const log = data.toString('utf-8'); // Collect output for rate limit detection (keep last 10KB) allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); @@ -792,7 +792,7 @@ export class AgentQueueManager { // Handle stderr - explicitly decode as UTF-8 childProcess.stderr?.on('data', (data: Buffer) => { - const log = data.toString('utf8'); + const log = data.toString('utf-8'); // Collect stderr for rate limit detection too allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); console.error('[Roadmap STDERR]', log); diff --git a/apps/frontend/src/main/api-validation-service.ts b/apps/frontend/src/main/api-validation-service.ts index a2f83549..cf5f5260 100644 --- a/apps/frontend/src/main/api-validation-service.ts +++ b/apps/frontend/src/main/api-validation-service.ts @@ -62,7 +62,7 @@ export async function validateOpenAIApiKey( let data = ''; res.on('data', (chunk: Buffer) => { - data += chunk; + data += chunk.toString('utf-8'); }); res.on('end', () => { diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index a86fd5c4..7884f9f1 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -368,7 +368,7 @@ async function fetchLatestStableRelease(): Promise { } response.on('data', (chunk) => { - data += chunk.toString(); + data += chunk.toString('utf-8'); }); response.on('end', () => { diff --git a/apps/frontend/src/main/changelog/generator.ts b/apps/frontend/src/main/changelog/generator.ts index a4c57b7d..7ee50084 100644 --- a/apps/frontend/src/main/changelog/generator.ts +++ b/apps/frontend/src/main/changelog/generator.ts @@ -156,7 +156,7 @@ export class ChangelogGenerator extends EventEmitter { let errorOutput = ''; childProcess.stdout?.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); output += chunk; this.debug('stdout chunk received', { chunkLength: chunk.length, totalOutput: output.length }); @@ -168,7 +168,7 @@ export class ChangelogGenerator extends EventEmitter { }); childProcess.stderr?.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); errorOutput += chunk; this.debug('stderr chunk received', { chunk: chunk.substring(0, 200) }); }); diff --git a/apps/frontend/src/main/changelog/version-suggester.ts b/apps/frontend/src/main/changelog/version-suggester.ts index cb8bb685..7facdbcc 100644 --- a/apps/frontend/src/main/changelog/version-suggester.ts +++ b/apps/frontend/src/main/changelog/version-suggester.ts @@ -65,11 +65,11 @@ export class VersionSuggester { let errorOutput = ''; childProcess.stdout?.on('data', (data: Buffer) => { - output += data.toString(); + output += data.toString('utf-8'); }); childProcess.stderr?.on('data', (data: Buffer) => { - errorOutput += data.toString(); + errorOutput += data.toString('utf-8'); }); childProcess.on('exit', (code: number | null) => { diff --git a/apps/frontend/src/main/fs-utils.ts b/apps/frontend/src/main/fs-utils.ts index 27c249a4..defab044 100644 --- a/apps/frontend/src/main/fs-utils.ts +++ b/apps/frontend/src/main/fs-utils.ts @@ -61,7 +61,7 @@ export function getWritablePath(originalPath: string, filename: string): string if (fs.existsSync(dir)) { // Try to write a test file const testFile = path.join(dir, `.write-test-${Date.now()}`); - fs.writeFileSync(testFile, ''); + fs.writeFileSync(testFile, '', 'utf-8'); // Cleanup test file - ignore errors (e.g., file locked on Windows) try { fs.unlinkSync(testFile); } catch { /* ignore cleanup failure */ } return originalPath; diff --git a/apps/frontend/src/main/insights/insights-executor.ts b/apps/frontend/src/main/insights/insights-executor.ts index 0c349b34..6630b4b1 100644 --- a/apps/frontend/src/main/insights/insights-executor.ts +++ b/apps/frontend/src/main/insights/insights-executor.ts @@ -133,7 +133,7 @@ export class InsightsExecutor extends EventEmitter { let stderrOutput = ''; proc.stdout?.on('data', (data: Buffer) => { - const text = data.toString(); + const text = data.toString('utf-8'); // Collect output for rate limit detection (keep last 10KB) allInsightsOutput = (allInsightsOutput + text).slice(-10000); @@ -159,7 +159,7 @@ export class InsightsExecutor extends EventEmitter { }); proc.stderr?.on('data', (data: Buffer) => { - const text = data.toString(); + const text = data.toString('utf-8'); // Collect stderr for rate limit detection and error reporting allInsightsOutput = (allInsightsOutput + text).slice(-10000); stderrOutput = (stderrOutput + text).slice(-2000); diff --git a/apps/frontend/src/main/insights/session-storage.ts b/apps/frontend/src/main/insights/session-storage.ts index 73ba067a..d4455b03 100644 --- a/apps/frontend/src/main/insights/session-storage.ts +++ b/apps/frontend/src/main/insights/session-storage.ts @@ -61,7 +61,7 @@ export class SessionStorage { } const sessionPath = this.paths.getSessionPath(projectPath, session.id); - writeFileSync(sessionPath, JSON.stringify(session, null, 2)); + writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf-8'); } /** @@ -152,7 +152,7 @@ export class SessionStorage { } const currentPath = this.paths.getCurrentSessionPath(projectPath); - writeFileSync(currentPath, JSON.stringify({ currentSessionId: sessionId }, null, 2)); + writeFileSync(currentPath, JSON.stringify({ currentSessionId: sessionId }, null, 2), 'utf-8'); } /** diff --git a/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts b/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts index f632b6de..bcf1acab 100644 --- a/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts @@ -179,15 +179,19 @@ export function registerProjectContextHandlers( '--output', indexOutputPath ], { cwd: project.path, - env: getAugmentedEnv() + env: { + ...getAugmentedEnv(), + PYTHONIOENCODING: 'utf-8', + PYTHONUTF8: '1' + } }); proc.stdout?.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); }); proc.stderr?.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('close', (code: number) => { diff --git a/apps/frontend/src/main/ipc-handlers/env-handlers.ts b/apps/frontend/src/main/ipc-handlers/env-handlers.ts index c0d7e227..ca2b6cad 100644 --- a/apps/frontend/src/main/ipc-handlers/env-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/env-handlers.ts @@ -562,17 +562,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ const envPath = path.join(project.path, project.autoBuildPath, '.env'); try { - // Read existing content if file exists + // Read existing content if file exists (atomic read, no TOCTOU) let existingContent: string | undefined; - if (existsSync(envPath)) { + try { existingContent = readFileSync(envPath, 'utf-8'); + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code !== 'ENOENT') throw readErr; + // File doesn't exist yet - existingContent stays undefined } // Generate new content const newContent = generateEnvContent(config, existingContent); // Write to file - writeFileSync(envPath, newContent); + writeFileSync(envPath, newContent, 'utf-8'); return { success: true }; } catch (error) { @@ -612,11 +615,11 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ let _stderr = ''; proc.stdout?.on('data', (data: Buffer) => { - _stdout += data.toString(); + _stdout += data.toString('utf-8'); }); proc.stderr?.on('data', (data: Buffer) => { - _stderr += data.toString(); + _stderr += data.toString('utf-8'); }); proc.on('close', (code: number | null) => { diff --git a/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts index 536bb95c..60715c86 100644 --- a/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/autofix-handlers.ts @@ -181,7 +181,7 @@ function saveAutoFixConfig(project: Project, config: AutoFixConfig): void { thinking_level: config.thinkingLevel, }; - fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8'); } /** @@ -422,7 +422,8 @@ async function startAutoFix( created_at: state.createdAt, updated_at: state.updatedAt, issue_url: sanitizedIssueUrl, - }, null, 2) + }, null, 2), + 'utf-8' ); sendProgress({ phase: 'creating_spec', issueNumber, progress: 70, message: 'Starting spec creation...' }); @@ -850,7 +851,7 @@ export function registerAutoFixHandlers( theme: b.theme ?? '', })); - fs.writeFileSync(tempFile, JSON.stringify(pythonBatches, null, 2)); + fs.writeFileSync(tempFile, JSON.stringify(pythonBatches, null, 2), 'utf-8'); // Comprehensive validation of GitHub module const validation = await validateGitHubModule(project); diff --git a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts index aa1a7aeb..3259583d 100644 --- a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts @@ -350,7 +350,7 @@ export function registerStartGhAuth(): void { }; ghProcess.stdout?.on('data', (data) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); output += chunk; debugLog('gh stdout:', chunk); // Try to extract device code as data comes in @@ -359,7 +359,7 @@ export function registerStartGhAuth(): void { }); ghProcess.stderr?.on('data', (data) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); errorOutput += chunk; debugLog('gh stderr:', chunk); // gh often outputs to stderr, so check there too diff --git a/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts b/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts index 7e71b126..5d61f5fd 100644 --- a/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/github/spec-utils.ts @@ -9,6 +9,7 @@ import type { Project, TaskMetadata } from '../../../shared/types'; import { withSpecNumberLock } from '../../utils/spec-number-lock'; import { debugLog } from './utils/logger'; import { labelMatchesWholeWord } from '../shared/label-utils'; +import { sanitizeText, sanitizeStringArray, sanitizeUrl } from '../shared/sanitize'; export interface SpecCreationData { specId: string; @@ -107,11 +108,17 @@ export async function createSpecForIssue( mkdirSync(specsDir, { recursive: true }); } + // Sanitize network-sourced data before writing to disk + const safeTitle = sanitizeText(issueTitle, 500); + const safeDescription = sanitizeText(taskDescription, 50000, true); + const safeGithubUrl = sanitizeUrl(githubUrl); + const safeLabels = sanitizeStringArray(labels, 50, 200); + // Use coordinated spec numbering with lock to prevent collisions return await withSpecNumberLock(project.path, async (lock) => { // Get next spec number from global scan (main + all worktrees) const specNumber = lock.getNextSpecNumber(project.autoBuildPath); - const slugifiedTitle = slugifyTitle(issueTitle); + const slugifiedTitle = slugifyTitle(safeTitle); const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; // Create spec directory (inside lock to ensure atomicity) @@ -123,8 +130,8 @@ export async function createSpecForIssue( // implementation_plan.json const implementationPlan = { - feature: issueTitle, - description: taskDescription, + feature: safeTitle, + description: safeDescription, created_at: now, updated_at: now, status: 'pending', @@ -132,27 +139,29 @@ export async function createSpecForIssue( }; writeFileSync( path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), - JSON.stringify(implementationPlan, null, 2) + JSON.stringify(implementationPlan, null, 2), + 'utf-8' ); // requirements.json const requirements = { - task_description: taskDescription, + task_description: safeDescription, workflow_type: 'feature' }; writeFileSync( path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), - JSON.stringify(requirements, null, 2) + JSON.stringify(requirements, null, 2), + 'utf-8' ); // Determine category from GitHub issue labels - const category = determineCategoryFromLabels(labels); + const category = determineCategoryFromLabels(safeLabels); // task_metadata.json const metadata: TaskMetadata = { sourceType: 'github', githubIssueNumber: issueNumber, - githubUrl, + githubUrl: safeGithubUrl, category, // Store baseBranch for worktree creation and QA comparison // This comes from project.settings.mainBranch or task-level override @@ -160,13 +169,14 @@ export async function createSpecForIssue( }; writeFileSync( path.join(specDir, 'task_metadata.json'), - JSON.stringify(metadata, null, 2) + JSON.stringify(metadata, null, 2), + 'utf-8' ); return { specId, specDir, - taskDescription, + taskDescription: safeDescription, metadata }; }); @@ -228,7 +238,7 @@ export function updateImplementationPlanStatus(specDir: string, status: string): const plan = JSON.parse(content); plan.status = status; plan.updated_at = new Date().toISOString(); - writeFileSync(planPath, JSON.stringify(plan, null, 2)); + writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8'); } catch (error) { // File doesn't exist or couldn't be read - this is expected for new specs // Log legitimate errors (malformed JSON, disk write failures, permission errors) diff --git a/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts index 75246aeb..d7026c7e 100644 --- a/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/triage-handlers.ts @@ -140,7 +140,7 @@ function saveTriageConfig(project: Project, config: TriageConfig): void { enable_triage_comments: config.enableComments, }; - fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8'); } /** diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts index d84aaa71..74a61e6a 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts @@ -194,7 +194,7 @@ export function runPythonSubprocess( }; child.stdout.on('data', (data: Buffer) => { - const text = data.toString(); + const text = data.toString('utf-8'); stdout += text; const lines = text.split('\n'); @@ -218,7 +218,7 @@ export function runPythonSubprocess( }); child.stderr.on('data', (data: Buffer) => { - const text = data.toString(); + const text = data.toString('utf-8'); stderr += text; const lines = text.split('\n'); diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts index f8becadc..eb598b1b 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts @@ -129,7 +129,7 @@ function saveAutoFixConfig(project: Project, config: GitLabAutoFixConfig): void thinking_level: config.thinkingLevel, }; - fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8'); } /** @@ -400,7 +400,8 @@ async function startAutoFix( created_at: state.createdAt, updated_at: state.updatedAt, issue_url: sanitizedIssueUrl, - }, null, 2) + }, null, 2), + 'utf-8' ); sendProgress(mainWindow, project.id, { @@ -621,7 +622,7 @@ export function registerAutoFixHandlers( reasoning: batch.reasoning, status: 'pending', created_at: new Date().toISOString(), - }, null, 2)); + }, null, 2), 'utf-8'); } const batches = getBatches(project); diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts index e339cd5c..7665ad5e 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts @@ -253,7 +253,7 @@ export function registerStartGlabAuth(): void { let browserOpened = false; glabProcess.stdout?.on('data', (data) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); output += chunk; debugLog('glab stdout:', chunk); @@ -268,7 +268,7 @@ export function registerStartGlabAuth(): void { }); glabProcess.stderr?.on('data', (data) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); errorOutput += chunk; debugLog('glab stderr:', chunk); }); diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts b/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts index c624a63f..0e357a55 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts @@ -8,6 +8,7 @@ import path from 'path'; import type { Project } from '../../../shared/types'; import type { GitLabAPIIssue, GitLabConfig } from './types'; import { labelMatchesWholeWord } from '../shared/label-utils'; +import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize'; /** * Simplified task info returned when creating a spec from a GitLab issue. @@ -102,33 +103,6 @@ function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' | return 'feature'; } -function stripControlChars(value: string, allowNewlines: boolean): string { - let sanitized = ''; - for (let i = 0; i < value.length; i += 1) { - const code = value.charCodeAt(i); - if (code === 0x0A || code === 0x0D || code === 0x09) { - if (allowNewlines) { - sanitized += value[i]; - } - continue; - } - if (code <= 0x1F || code === 0x7F) { - continue; - } - sanitized += value[i]; - } - return sanitized; -} - -function sanitizeText(value: unknown, maxLength: number, allowNewlines = false): string { - if (typeof value !== 'string') return ''; - let sanitized = stripControlChars(value, allowNewlines).trim(); - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength); - } - return sanitized; -} - function sanitizeIssueNumber(value: unknown): number { const issueId = typeof value === 'number' ? value : Number(value); if (!Number.isInteger(issueId) || issueId <= 0) { @@ -141,21 +115,6 @@ function sanitizeIssueState(value: unknown): 'opened' | 'closed' { return value === 'closed' ? 'closed' : 'opened'; } -function sanitizeStringArray(value: unknown, maxItems: number, maxLength: number): string[] { - if (!Array.isArray(value)) return []; - const sanitized: string[] = []; - for (const entry of value) { - const cleanEntry = sanitizeText(entry, maxLength); - if (cleanEntry) { - sanitized.push(cleanEntry); - } - if (sanitized.length >= maxItems) { - break; - } - } - return sanitized; -} - function sanitizeAssignees(value: unknown): Array<{ username: string }> { if (!Array.isArray(value)) return []; const sanitized: Array<{ username: string }> = []; diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/triage-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/triage-handlers.ts index 87551319..1fba307c 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/triage-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/triage-handlers.ts @@ -20,6 +20,7 @@ import type { GitLabTriageResult, GitLabTriageCategory, } from './types'; +import { sanitizeStringArray } from '../shared/sanitize'; // Debug logging function debugLog(message: string, ...args: unknown[]): void { @@ -36,18 +37,6 @@ const TRIAGE_CATEGORIES: GitLabTriageCategory[] = [ 'feature_creep', ]; -function stripControlChars(value: string): string { - let sanitized = ''; - for (let i = 0; i < value.length; i += 1) { - const code = value.charCodeAt(i); - if (code <= 0x1F || code === 0x7F) { - continue; - } - sanitized += value[i]; - } - return sanitized; -} - function sanitizeIssueIid(value: unknown): number | null { const issueIid = typeof value === 'number' ? value : Number(value); if (!Number.isInteger(issueIid) || issueIid <= 0) { @@ -60,15 +49,8 @@ function sanitizeCategory(value: unknown): GitLabTriageCategory { return TRIAGE_CATEGORIES.includes(value as GitLabTriageCategory) ? (value as GitLabTriageCategory) : 'feature'; } -function sanitizeLabel(value: unknown): string { - if (typeof value !== 'string') return ''; - const sanitized = stripControlChars(value).trim(); - return sanitized.length > 50 ? sanitized.substring(0, 50) : sanitized; -} - function sanitizeLabels(values: string[]): string[] { - const sanitized = values.map(label => sanitizeLabel(label)).filter(label => Boolean(label)); - return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized; + return sanitizeStringArray(values, 50, 50); } function sanitizeConfidence(value: number): number { @@ -171,7 +153,7 @@ function saveTriageConfig(project: Project, config: GitLabTriageConfig): void { triage_enable_comments: config.enableComments, }; - fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8'); } /** @@ -440,7 +422,8 @@ export function registerTriageHandlers( // Save result fs.writeFileSync( path.join(triageDir, `triage_${sanitizedResult.issue_iid}.json`), - JSON.stringify(sanitizedResult, null, 2) + JSON.stringify(sanitizedResult, null, 2), + 'utf-8' ); results.push(result); diff --git a/apps/frontend/src/main/ipc-handlers/ideation/file-utils.ts b/apps/frontend/src/main/ipc-handlers/ideation/file-utils.ts index 7e1771ac..c1258128 100644 --- a/apps/frontend/src/main/ipc-handlers/ideation/file-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/ideation/file-utils.ts @@ -28,7 +28,7 @@ export function readIdeationFile(ideationPath: string): RawIdeationData | null { */ export function writeIdeationFile(ideationPath: string, data: RawIdeationData): void { try { - writeFileSync(ideationPath, JSON.stringify(data, null, 2)); + writeFileSync(ideationPath, JSON.stringify(data, null, 2), 'utf-8'); } catch (error) { throw new Error( error instanceof Error ? error.message : 'Failed to write ideation file' diff --git a/apps/frontend/src/main/ipc-handlers/ideation/task-converter.ts b/apps/frontend/src/main/ipc-handlers/ideation/task-converter.ts index 4141f032..075a1e48 100644 --- a/apps/frontend/src/main/ipc-handlers/ideation/task-converter.ts +++ b/apps/frontend/src/main/ipc-handlers/ideation/task-converter.ts @@ -155,7 +155,8 @@ function createSpecFiles( }; writeFileSync( path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), - JSON.stringify(initialPlan, null, 2) + JSON.stringify(initialPlan, null, 2), + 'utf-8' ); // Create initial spec.md @@ -172,7 +173,7 @@ ${idea.rationale} --- *This spec was created from ideation and is pending detailed specification.* `; - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent); + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent, 'utf-8'); } /** @@ -249,7 +250,7 @@ export async function convertIdeaToTask( // Save metadata const metadataPath = path.join(specDir, 'task_metadata.json'); - writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); // Update idea status to archived (converted ideas are archived) idea.status = 'archived'; diff --git a/apps/frontend/src/main/ipc-handlers/insights-handlers.ts b/apps/frontend/src/main/ipc-handlers/insights-handlers.ts index d050a6ed..dd951418 100644 --- a/apps/frontend/src/main/ipc-handlers/insights-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/insights-handlers.ts @@ -215,11 +215,11 @@ export function registerInsightsHandlers(getMainWindow: () => BrowserWindow | nu }; const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); - writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2), 'utf-8'); // Save task metadata const metadataPath = path.join(specDir, "task_metadata.json"); - writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2), 'utf-8'); // Create the task object const task: Task = { diff --git a/apps/frontend/src/main/ipc-handlers/linear-handlers.ts b/apps/frontend/src/main/ipc-handlers/linear-handlers.ts index 15668b89..8b165c9a 100644 --- a/apps/frontend/src/main/ipc-handlers/linear-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/linear-handlers.ts @@ -6,6 +6,7 @@ import path from 'path'; import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs'; import { projectStore } from '../project-store'; import { parseEnvFile } from './utils'; +import { sanitizeText, sanitizeUrl } from './shared/sanitize'; import { AgentManager } from '../agent'; @@ -446,18 +447,27 @@ export function registerLinearHandlers( // Create tasks for each imported issue for (const issue of data.issues.nodes) { try { - // Build description from Linear issue - const labels = issue.labels.nodes.map(l => l.name).join(', '); - const description = `# ${issue.title} + // Sanitize network-sourced data before writing to disk + const safeTitle = sanitizeText(issue.title, 500); + const safeIdentifier = sanitizeText(issue.identifier, 50); + const safeDescription = sanitizeText(issue.description ?? '', 50000, true); + const safePriorityLabel = sanitizeText(issue.priorityLabel, 100); + const safeStateName = sanitizeText(issue.state.name, 100); + const safeUrl = sanitizeUrl(issue.url); + const safeLabels = issue.labels.nodes.map(l => sanitizeText(l.name, 200)).filter(Boolean); -**Linear Issue:** [${issue.identifier}](${issue.url}) -**Priority:** ${issue.priorityLabel} -**Status:** ${issue.state.name} -${labels ? `**Labels:** ${labels}` : ''} + // Build description from Linear issue + const labelsStr = safeLabels.join(', '); + const description = `# ${safeTitle} + +**Linear Issue:** [${safeIdentifier}](${safeUrl}) +**Priority:** ${safePriorityLabel} +**Status:** ${safeStateName} +${labelsStr ? `**Labels:** ${labelsStr}` : ''} ## Description -${issue.description || 'No description provided.'} +${safeDescription || 'No description provided.'} `; // Find next available spec number @@ -476,7 +486,7 @@ ${issue.description || 'No description provided.'} } // Create spec ID with zero-padded number and slugified title - const slugifiedTitle = issue.title + const slugifiedTitle = safeTitle .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') @@ -490,31 +500,31 @@ ${issue.description || 'No description provided.'} // Create initial implementation_plan.json const now = new Date().toISOString(); const implementationPlan = { - feature: issue.title, + feature: safeTitle, description: description, created_at: now, updated_at: now, status: 'pending', phases: [] }; - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2), 'utf-8'); // Create requirements.json const requirements = { task_description: description, workflow_type: 'feature' }; - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2), 'utf-8'); // Build metadata const metadata: TaskMetadata = { sourceType: 'linear', - linearIssueId: issue.id, - linearIdentifier: issue.identifier, - linearUrl: issue.url, + linearIssueId: sanitizeText(issue.id, 100), + linearIdentifier: safeIdentifier, + linearUrl: safeUrl, category: 'feature' }; - writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8'); // Start spec creation with the existing spec directory agentManager.startSpecCreation(specId, project.path, description, specDir, metadata); diff --git a/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts b/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts index b7f07dc8..5237ab03 100644 --- a/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/mcp-handlers.ts @@ -461,7 +461,7 @@ async function testCommandConnection(server: CustomMcpServer, startTime: number) proc.stdin.write(initRequest); proc.stdout.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); // Try to parse JSON response try { @@ -490,7 +490,7 @@ async function testCommandConnection(server: CustomMcpServer, startTime: number) }); proc.stderr.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('error', (error) => { diff --git a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts index 0f113053..2ceb6a17 100644 --- a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts @@ -272,11 +272,11 @@ async function executeOllamaDetector( let stderr = ''; proc.stdout.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); }); proc.stderr.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); // Single timeout mechanism to avoid race condition @@ -749,11 +749,11 @@ export function registerMemoryHandlers(): void { let stderrBuffer = ''; // Buffer for NDJSON parsing proc.stdout.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); }); proc.stderr.on('data', (data) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stderr += chunk; stderrBuffer += chunk; diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index b8f11695..0173d87c 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -35,24 +35,24 @@ function getFeatureSettings(): { model?: string; thinkingLevel?: string } { const settingsPath = path.join(app.getPath("userData"), "settings.json"); try { - if (existsSync(settingsPath)) { - const content = readFileSync(settingsPath, "utf-8"); - const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) }; + const content = readFileSync(settingsPath, "utf-8"); + const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) }; - // Get roadmap-specific settings - const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS; - const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING; + // Get roadmap-specific settings + const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS; + const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING; - return { - model: featureModels.roadmap, - thinkingLevel: featureThinking.roadmap, - }; - } + return { + model: featureModels.roadmap, + thinkingLevel: featureThinking.roadmap, + }; } catch (error) { - debugError("[Roadmap Handler] Failed to read feature settings:", error); + // Return defaults if settings file doesn't exist (ENOENT) or fails to parse + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugError("[Roadmap Handler] Failed to read feature settings:", error); + } } - // Return defaults if settings file doesn't exist or fails to parse return { model: DEFAULT_FEATURE_MODELS.roadmap, thinkingLevel: DEFAULT_FEATURE_THINKING.roadmap, @@ -378,12 +378,16 @@ export function registerRoadmapHandlers( AUTO_BUILD_PATHS.ROADMAP_FILE ); - if (!existsSync(roadmapPath)) { - return { success: false, error: "Roadmap not found" }; - } - try { - const content = readFileSync(roadmapPath, "utf-8"); + let content: string; + try { + content = readFileSync(roadmapPath, "utf-8"); + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: "Roadmap not found" }; + } + throw readErr; + } const existingRoadmap = JSON.parse(content); // Transform camelCase features back to snake_case for JSON file @@ -408,7 +412,7 @@ export function registerRoadmapHandlers( existingRoadmap.metadata = existingRoadmap.metadata || {}; existingRoadmap.metadata.updated_at = new Date().toISOString(); - writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2)); + writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2), 'utf-8'); return { success: true }; } catch (error) { @@ -439,12 +443,16 @@ export function registerRoadmapHandlers( AUTO_BUILD_PATHS.ROADMAP_FILE ); - if (!existsSync(roadmapPath)) { - return { success: false, error: "Roadmap not found" }; - } - try { - const content = readFileSync(roadmapPath, "utf-8"); + let content: string; + try { + content = readFileSync(roadmapPath, "utf-8"); + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: "Roadmap not found" }; + } + throw readErr; + } const roadmap = JSON.parse(content); // Find and update the feature @@ -457,7 +465,7 @@ export function registerRoadmapHandlers( roadmap.metadata = roadmap.metadata || {}; roadmap.metadata.updated_at = new Date().toISOString(); - writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2), 'utf-8'); return { success: true }; } catch (error) { @@ -483,12 +491,16 @@ export function registerRoadmapHandlers( AUTO_BUILD_PATHS.ROADMAP_FILE ); - if (!existsSync(roadmapPath)) { - return { success: false, error: "Roadmap not found" }; - } - try { - const content = readFileSync(roadmapPath, "utf-8"); + let content: string; + try { + content = readFileSync(roadmapPath, "utf-8"); + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: "Roadmap not found" }; + } + throw readErr; + } const roadmap = JSON.parse(content); // Find the feature @@ -562,7 +574,8 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" }; writeFileSync( path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), - JSON.stringify(implementationPlan, null, 2) + JSON.stringify(implementationPlan, null, 2), + 'utf-8' ); // Create requirements.json @@ -572,11 +585,12 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" }; writeFileSync( path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), - JSON.stringify(requirements, null, 2) + JSON.stringify(requirements, null, 2), + 'utf-8' ); // Create spec.md (required by backend spec creation process) - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription); + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription, 'utf-8'); // Build metadata const metadata: TaskMetadata = { @@ -584,7 +598,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" featureId: feature.id, category: "feature", }; - writeFileSync(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2)); + writeFileSync(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2), 'utf-8'); // NOTE: We do NOT auto-start spec creation here - user should explicitly start the task // from the kanban board when they're ready @@ -594,7 +608,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" feature.linked_spec_id = specId; roadmap.metadata = roadmap.metadata || {}; roadmap.metadata.updated_at = new Date().toISOString(); - writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2), 'utf-8'); // Create task object const task: Task = { @@ -663,7 +677,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" is_running: isRunning, }; - writeFileSync(progressPath, JSON.stringify(fileData, null, 2)); + writeFileSync(progressPath, JSON.stringify(fileData, null, 2), 'utf-8'); debugLog("[Roadmap Handler] Saved progress checkpoint:", { projectId, phase: progressData.phase }); return { success: true }; diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index abd7702a..82df85a6 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -161,7 +161,7 @@ export function registerSettingsHandlers( // Persist migration changes if (needsSave) { try { - writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); } catch (error) { console.error('[SETTINGS_GET] Failed to persist migration:', error); // Continue anyway - settings will be migrated in-memory for this session @@ -202,7 +202,7 @@ export function registerSettingsHandlers( } } - writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2)); + writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), 'utf-8'); // Apply Python path if changed if (settings.pythonPath || settings.autoBuildPath) { @@ -724,7 +724,7 @@ export function registerSettingsHandlers( } } - writeFileSync(envPath, lines.join('\n')); + writeFileSync(envPath, lines.join('\n'), 'utf-8'); return { success: true }; } catch (error) { diff --git a/apps/frontend/src/main/ipc-handlers/shared/__tests__/sanitize.test.ts b/apps/frontend/src/main/ipc-handlers/shared/__tests__/sanitize.test.ts new file mode 100644 index 00000000..74b5694f --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/shared/__tests__/sanitize.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import { stripControlChars, sanitizeText, sanitizeStringArray, sanitizeUrl } from '../sanitize'; + +describe('stripControlChars', () => { + describe('basic functionality', () => { + it('should return empty string for empty input', () => { + expect(stripControlChars('', false)).toBe(''); + expect(stripControlChars('', true)).toBe(''); + }); + + it('should pass through plain text unchanged', () => { + const input = 'Hello, World!'; + expect(stripControlChars(input, false)).toBe(input); + expect(stripControlChars(input, true)).toBe(input); + }); + + it('should strip null character (0x00)', () => { + expect(stripControlChars('hello\x00world', false)).toBe('helloworld'); + }); + + it('should strip bell character (0x07)', () => { + expect(stripControlChars('hello\x07world', false)).toBe('helloworld'); + }); + + it('should strip backspace (0x08)', () => { + expect(stripControlChars('hello\x08world', false)).toBe('helloworld'); + }); + + it('should strip escape character (0x1B)', () => { + expect(stripControlChars('hello\x1Bworld', false)).toBe('helloworld'); + }); + + it('should strip DEL character (0x7F)', () => { + expect(stripControlChars('hello\x7Fworld', false)).toBe('helloworld'); + }); + + it('should strip all ASCII control characters (0x00-0x1F)', () => { + let input = ''; + for (let i = 0; i <= 0x1F; i++) { + input += String.fromCharCode(i); + } + input += 'visible'; + // When allowNewlines is false, only 'visible' should remain + expect(stripControlChars(input, false)).toBe('visible'); + }); + }); + + describe('newline handling', () => { + it('should strip newlines when allowNewlines is false', () => { + expect(stripControlChars('hello\nworld', false)).toBe('helloworld'); + expect(stripControlChars('hello\rworld', false)).toBe('helloworld'); + expect(stripControlChars('hello\r\nworld', false)).toBe('helloworld'); + }); + + it('should preserve newlines when allowNewlines is true', () => { + expect(stripControlChars('hello\nworld', true)).toBe('hello\nworld'); + expect(stripControlChars('hello\rworld', true)).toBe('hello\rworld'); + expect(stripControlChars('hello\r\nworld', true)).toBe('hello\r\nworld'); + }); + + it('should preserve tabs when allowNewlines is true', () => { + expect(stripControlChars('hello\tworld', true)).toBe('hello\tworld'); + }); + + it('should strip tabs when allowNewlines is false', () => { + expect(stripControlChars('hello\tworld', false)).toBe('helloworld'); + }); + }); + + describe('Unicode handling', () => { + it('should preserve non-ASCII Unicode characters', () => { + const input = '日本語テスト 🎉 émojis'; + expect(stripControlChars(input, false)).toBe(input); + }); + + it('should preserve right-to-left text', () => { + const input = 'مرحبا بالعالم'; + expect(stripControlChars(input, false)).toBe(input); + }); + }); +}); + +describe('sanitizeText', () => { + describe('type checking', () => { + it('should return empty string for non-string input', () => { + expect(sanitizeText(null, 100)).toBe(''); + expect(sanitizeText(undefined, 100)).toBe(''); + expect(sanitizeText(123, 100)).toBe(''); + expect(sanitizeText({}, 100)).toBe(''); + expect(sanitizeText([], 100)).toBe(''); + }); + }); + + describe('length enforcement', () => { + it('should truncate strings exceeding maxLength', () => { + expect(sanitizeText('hello world', 5)).toBe('hello'); + }); + + it('should not truncate strings within maxLength', () => { + expect(sanitizeText('hello', 10)).toBe('hello'); + }); + + it('should handle zero maxLength', () => { + expect(sanitizeText('hello', 0)).toBe(''); + }); + }); + + describe('trimming', () => { + it('should trim leading and trailing whitespace', () => { + expect(sanitizeText(' hello ', 100)).toBe('hello'); + }); + + it('should trim before applying maxLength', () => { + expect(sanitizeText(' hello ', 3)).toBe('hel'); + }); + }); + + describe('control character stripping', () => { + it('should strip control characters', () => { + expect(sanitizeText('hello\x00\x07world', 100)).toBe('helloworld'); + }); + + it('should strip newlines by default', () => { + expect(sanitizeText('hello\nworld', 100)).toBe('helloworld'); + }); + + it('should preserve newlines when allowNewlines is true', () => { + expect(sanitizeText('hello\nworld', 100, true)).toBe('hello\nworld'); + }); + }); +}); + +describe('sanitizeStringArray', () => { + describe('type checking', () => { + it('should return empty array for non-array input', () => { + expect(sanitizeStringArray(null, 10, 50)).toEqual([]); + expect(sanitizeStringArray(undefined, 10, 50)).toEqual([]); + expect(sanitizeStringArray('string', 10, 50)).toEqual([]); + expect(sanitizeStringArray(123, 10, 50)).toEqual([]); + expect(sanitizeStringArray({}, 10, 50)).toEqual([]); + }); + }); + + describe('item count limiting', () => { + it('should limit number of items to maxItems', () => { + const input = ['a', 'b', 'c', 'd', 'e']; + expect(sanitizeStringArray(input, 3, 50)).toEqual(['a', 'b', 'c']); + }); + + it('should return all items if under maxItems', () => { + const input = ['a', 'b']; + expect(sanitizeStringArray(input, 5, 50)).toEqual(['a', 'b']); + }); + }); + + describe('item sanitization', () => { + it('should sanitize each item with maxLength', () => { + const input = ['hello world', 'test']; + expect(sanitizeStringArray(input, 10, 5)).toEqual(['hello', 'test']); + }); + + it('should filter out non-string items', () => { + const input = ['valid', 123, null, 'also valid', undefined]; + expect(sanitizeStringArray(input, 10, 50)).toEqual(['valid', 'also valid']); + }); + + it('should filter out empty strings after sanitization', () => { + const input = ['valid', '', ' ', 'also valid']; + expect(sanitizeStringArray(input, 10, 50)).toEqual(['valid', 'also valid']); + }); + }); + + describe('control character handling', () => { + it('should strip control characters from items', () => { + const input = ['hello\x00world', 'test\x07data']; + expect(sanitizeStringArray(input, 10, 50)).toEqual(['helloworld', 'testdata']); + }); + }); +}); + +describe('sanitizeUrl', () => { + describe('valid URLs', () => { + it('should accept valid HTTPS URLs', () => { + expect(sanitizeUrl('https://example.com')).toBe('https://example.com/'); + }); + + it('should accept valid HTTP URLs', () => { + expect(sanitizeUrl('http://example.com')).toBe('http://example.com/'); + }); + + it('should accept URLs with paths', () => { + expect(sanitizeUrl('https://example.com/path/to/resource')).toBe('https://example.com/path/to/resource'); + }); + + it('should accept URLs with query parameters', () => { + expect(sanitizeUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com/?foo=bar&baz=qux'); + }); + + it('should accept URLs with fragments', () => { + expect(sanitizeUrl('https://example.com#section')).toBe('https://example.com/#section'); + }); + + it('should accept URLs with port numbers', () => { + expect(sanitizeUrl('https://example.com:8080')).toBe('https://example.com:8080/'); + }); + }); + + describe('invalid URLs', () => { + it('should reject non-string input', () => { + expect(sanitizeUrl(null)).toBe(''); + expect(sanitizeUrl(undefined)).toBe(''); + expect(sanitizeUrl(123)).toBe(''); + }); + + it('should reject javascript: URIs', () => { + expect(sanitizeUrl('javascript:alert(1)')).toBe(''); + }); + + it('should reject data: URIs', () => { + expect(sanitizeUrl('data:text/html,')).toBe(''); + }); + + it('should reject file: URIs', () => { + expect(sanitizeUrl('file:///etc/passwd')).toBe(''); + }); + + it('should reject URLs with credentials', () => { + expect(sanitizeUrl('https://user:pass@example.com')).toBe(''); + expect(sanitizeUrl('https://user@example.com')).toBe(''); + }); + + it('should reject malformed URLs', () => { + expect(sanitizeUrl('not-a-url')).toBe(''); + expect(sanitizeUrl('://missing-protocol.com')).toBe(''); + }); + + it('should reject URLs exceeding maxLength', () => { + const longUrl = 'https://example.com/' + 'a'.repeat(3000); + expect(sanitizeUrl(longUrl)).toBe(''); + }); + }); + + describe('control character handling', () => { + it('should strip control characters before parsing', () => { + expect(sanitizeUrl('https://example\x00.com')).toBe('https://example.com/'); + }); + }); + + describe('length limits', () => { + it('should respect custom maxLength', () => { + const url = 'https://example.com/path'; + expect(sanitizeUrl(url, 10)).toBe(''); + }); + + it('should accept URLs within custom maxLength', () => { + const url = 'https://example.com'; + expect(sanitizeUrl(url, 100)).toBe('https://example.com/'); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/shared/sanitize.ts b/apps/frontend/src/main/ipc-handlers/shared/sanitize.ts new file mode 100644 index 00000000..82116355 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/shared/sanitize.ts @@ -0,0 +1,76 @@ +/** + * Shared sanitization utilities for network data before writing to disk. + * Prevents control character injection and enforces length limits on + * data from external APIs (GitHub, GitLab, Linear, etc.). + */ + +/** + * Strip control characters from a string. + * Keeps tabs, newlines, and carriage returns only when allowNewlines is true. + */ +export function stripControlChars(value: string, allowNewlines: boolean): string { + let sanitized = ''; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code === 0x0A || code === 0x0D || code === 0x09) { + if (allowNewlines) { + sanitized += value[i]; + } + continue; + } + if (code <= 0x1F || code === 0x7F) { + continue; + } + sanitized += value[i]; + } + return sanitized; +} + +/** + * Sanitize a text value: type-check, strip control chars, enforce max length. + */ +export function sanitizeText(value: unknown, maxLength: number, allowNewlines = false): string { + if (typeof value !== 'string') return ''; + let sanitized = stripControlChars(value, allowNewlines).trim(); + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength); + } + return sanitized; +} + +/** + * Sanitize an array of strings: type-check each entry, strip control chars, + * enforce per-item length and max item count. + */ +export function sanitizeStringArray(value: unknown, maxItems: number, maxLength: number): string[] { + if (!Array.isArray(value)) return []; + const sanitized: string[] = []; + for (const entry of value) { + const cleanEntry = sanitizeText(entry, maxLength); + if (cleanEntry) { + sanitized.push(cleanEntry); + } + if (sanitized.length >= maxItems) { + break; + } + } + return sanitized; +} + +/** + * Sanitize a URL value: validate format, strip control chars, enforce length. + * Returns empty string for invalid URLs. + */ +export function sanitizeUrl(value: unknown, maxLength = 2000): string { + if (typeof value !== 'string') return ''; + const cleaned = stripControlChars(value, false).trim(); + if (cleaned.length > maxLength) return ''; + try { + const parsed = new URL(cleaned); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return ''; + if (parsed.username || parsed.password) return ''; + return parsed.toString(); + } catch { + return ''; + } +} diff --git a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts index 16c85263..bff3a2e6 100644 --- a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts @@ -166,12 +166,12 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { }; const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); - writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2), 'utf-8'); // Save task metadata if provided if (taskMetadata) { const metadataPath = path.join(specDir, 'task_metadata.json'); - writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2), 'utf-8'); } // Create requirements.json with attached images @@ -190,7 +190,7 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { } const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); - writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2)); + writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2), 'utf-8'); // Create the task object const task: Task = { @@ -331,51 +331,53 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { // Update implementation_plan.json const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); - if (existsSync(planPath)) { - try { - const planContent = readFileSync(planPath, 'utf-8'); - const plan = JSON.parse(planContent); + try { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); - if (finalTitle !== undefined) { - plan.feature = finalTitle; - } - if (updates.description !== undefined) { - plan.description = updates.description; - } - plan.updated_at = new Date().toISOString(); + if (finalTitle !== undefined) { + plan.feature = finalTitle; + } + if (updates.description !== undefined) { + plan.description = updates.description; + } + plan.updated_at = new Date().toISOString(); - writeFileSync(planPath, JSON.stringify(plan, null, 2)); - } catch { - // Plan file might not be valid JSON, continue anyway + writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8'); + } catch (planErr: unknown) { + // File missing or invalid JSON - continue anyway + if ((planErr as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[TASK_UPDATE] Error updating implementation plan:', planErr); } } // Update spec.md if it exists const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); - if (existsSync(specPath)) { - try { - let specContent = readFileSync(specPath, 'utf-8'); + try { + let specContent = readFileSync(specPath, 'utf-8'); - // Update title (first # heading) - if (finalTitle !== undefined) { - specContent = specContent.replace( - /^#\s+.*$/m, - `# ${finalTitle}` - ); - } + // Update title (first # heading) + if (finalTitle !== undefined) { + specContent = specContent.replace( + /^#\s+.*$/m, + `# ${finalTitle}` + ); + } - // Update description (## Overview section content) - if (updates.description !== undefined) { - // Replace content between ## Overview and the next ## section - specContent = specContent.replace( - /(## Overview\n)([\s\S]*?)((?=\n## )|$)/, - `$1${updates.description}\n\n$3` - ); - } + // Update description (## Overview section content) + if (updates.description !== undefined) { + // Replace content between ## Overview and the next ## section + specContent = specContent.replace( + /(## Overview\n)([\s\S]*?)((?=\n## )|$)/, + `$1${updates.description}\n\n$3` + ); + } - writeFileSync(specPath, specContent); - } catch { - // Spec file update failed, continue anyway + writeFileSync(specPath, specContent, 'utf-8'); + } catch (specErr: unknown) { + // File missing or update failed - continue anyway + if ((specErr as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[TASK_UPDATE] Error updating spec.md:', specErr); } } @@ -421,27 +423,27 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { // Update task_metadata.json const metadataPath = path.join(specDir, 'task_metadata.json'); try { - writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2), 'utf-8'); } catch (err) { console.error('Failed to update task_metadata.json:', err); } // Update requirements.json if it exists const requirementsPath = path.join(specDir, 'requirements.json'); - if (existsSync(requirementsPath)) { - try { - const requirementsContent = readFileSync(requirementsPath, 'utf-8'); - const requirements = JSON.parse(requirementsContent); + try { + const requirementsContent = readFileSync(requirementsPath, 'utf-8'); + const requirements = JSON.parse(requirementsContent); - if (updates.description !== undefined) { - requirements.task_description = updates.description; - } - if (updates.metadata.category) { - requirements.workflow_type = updates.metadata.category; - } + if (updates.description !== undefined) { + requirements.task_description = updates.description; + } + if (updates.metadata.category) { + requirements.workflow_type = updates.metadata.category; + } - writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2)); - } catch (err) { + writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2), 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to update requirements.json:', err); } } diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 686082c0..99369ffd 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -384,7 +384,8 @@ export function registerTaskExecutionHandlers( try { writeFileSync( qaReportPath, - `# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n` + `# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n`, + 'utf-8' ); } catch (error) { console.error('[TASK_REVIEW] Failed to write QA report:', error); @@ -521,7 +522,8 @@ export function registerTaskExecutionHandlers( try { writeFileSync( fixRequestPath, - `# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}${imageReferences}\n\nCreated at: ${new Date().toISOString()}\n` + `# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}${imageReferences}\n\nCreated at: ${new Date().toISOString()}\n`, + 'utf-8' ); } catch (error) { console.error('[TASK_REVIEW] Failed to write QA fix request:', error); diff --git a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts index f44aec1d..f0020dbe 100644 --- a/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/task/plan-file-utils.ts @@ -111,7 +111,7 @@ export async function persistPlanStatus(planPath: string, status: TaskStatus, pr plan.planStatus = mapStatusToPlanStatus(status); plan.updated_at = new Date().toISOString(); - writeFileSync(planPath, JSON.stringify(plan, null, 2)); + writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8'); console.warn(`[plan-file-utils] Successfully persisted status: ${status} to implementation_plan.json`); // Invalidate tasks cache since status changed @@ -167,7 +167,7 @@ export function persistPlanStatusSync(planPath: string, status: TaskStatus, proj plan.planStatus = mapStatusToPlanStatus(status); plan.updated_at = new Date().toISOString(); - writeFileSync(planPath, JSON.stringify(plan, null, 2)); + writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8'); // Invalidate tasks cache since status changed if (projectId) { @@ -207,7 +207,7 @@ export async function updatePlanFile>( // Add updated_at timestamp - use type assertion since T extends Record (updatedPlan as Record).updated_at = new Date().toISOString(); - writeFileSync(planPath, JSON.stringify(updatedPlan, null, 2)); + writeFileSync(planPath, JSON.stringify(updatedPlan, null, 2), 'utf-8'); console.warn(`[plan-file-utils] Successfully updated implementation_plan.json`); return updatedPlan; } catch (err) { @@ -267,7 +267,7 @@ export async function createPlanIfNotExists( } } - writeFileSync(planPath, JSON.stringify(plan, null, 2)); + writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8'); }); } @@ -301,7 +301,7 @@ export function updateTaskMetadataPrUrl(metadataPath: string, prUrl: string): bo mkdirSync(path.dirname(metadataPath), { recursive: true }); // Write back - writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); return true; } catch (err) { console.warn(`[plan-file-utils] Could not update metadata at ${metadataPath}:`, err); diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 545d8d5c..5e490e2f 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -2043,13 +2043,13 @@ export function registerWorktreeHandlers( }, MERGE_TIMEOUT_MS); mergeProcess.stdout.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stdout += chunk; debug('STDOUT:', chunk); }); mergeProcess.stderr.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stderr += chunk; debug('STDERR:', chunk); }); @@ -2243,7 +2243,7 @@ export function registerWorktreeHandlers( plan.stagedAt = new Date().toISOString(); plan.stagedInMainProject = true; } - await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2)); + await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2), 'utf-8'); // Verify the write succeeded by reading back const verifyContent = await fsPromises.readFile(planPath, 'utf-8'); @@ -2482,13 +2482,13 @@ export function registerWorktreeHandlers( let stderr = ''; previewProcess.stdout.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stdout += chunk; console.warn('[IPC] merge-preview stdout:', chunk); }); previewProcess.stderr.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stderr += chunk; console.warn('[IPC] merge-preview stderr:', chunk); }); @@ -2929,7 +2929,7 @@ export function registerWorktreeHandlers( delete plan.stagedAt; plan.updated_at = new Date().toISOString(); - await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2)); + await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2), 'utf-8'); // Also update worktree plan if it exists const worktreePath = findTaskWorktree(project.path, task.specId); @@ -2941,7 +2941,7 @@ export function registerWorktreeHandlers( delete worktreePlan.stagedInMainProject; delete worktreePlan.stagedAt; worktreePlan.updated_at = new Date().toISOString(); - await fsPromises.writeFile(worktreePlanPath, JSON.stringify(worktreePlan, null, 2)); + await fsPromises.writeFile(worktreePlanPath, JSON.stringify(worktreePlan, null, 2), 'utf-8'); } catch (e) { // Non-fatal - worktree plan update is best-effort // ENOENT is expected when worktree has no plan file @@ -3097,13 +3097,13 @@ export function registerWorktreeHandlers( }, PR_CREATION_TIMEOUT_MS); createPRProcess.stdout.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stdout += chunk; debug('STDOUT:', chunk); }); createPRProcess.stderr.on('data', (data: Buffer) => { - const chunk = data.toString(); + const chunk = data.toString('utf-8'); stderr += chunk; debug('STDERR:', chunk); }); diff --git a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts index bb8febd2..05639c1c 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts @@ -303,7 +303,7 @@ function saveWorktreeConfig(projectPath: string, name: string, config: TerminalW const metadataDir = getTerminalWorktreeMetadataDir(projectPath); mkdirSync(metadataDir, { recursive: true }); const metadataPath = getTerminalWorktreeMetadataPath(projectPath, name); - writeFileSync(metadataPath, JSON.stringify(config, null, 2)); + writeFileSync(metadataPath, JSON.stringify(config, null, 2), 'utf-8'); } function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktreeConfig | null { diff --git a/apps/frontend/src/main/log-service.ts b/apps/frontend/src/main/log-service.ts index 58ba24b7..62a0eb4e 100644 --- a/apps/frontend/src/main/log-service.ts +++ b/apps/frontend/src/main/log-service.ts @@ -61,7 +61,7 @@ export class LogService { '' ].join('\n'); - writeFileSync(logFile, header); + writeFileSync(logFile, header, 'utf-8'); // Track active session this.activeSessions.set(taskId, { @@ -167,7 +167,7 @@ export class LogService { const logsDir = path.dirname(session.logPath); const latestPath = path.join(logsDir, 'latest.log'); const logContent = readFileSync(session.logPath, 'utf-8'); - writeFileSync(latestPath, logContent); + writeFileSync(latestPath, logContent, 'utf-8'); } catch (error) { console.error(`[LogService] Failed to end session for task ${taskId}:`, error); } diff --git a/apps/frontend/src/main/memory-service.ts b/apps/frontend/src/main/memory-service.ts index 76f099b9..00e88936 100644 --- a/apps/frontend/src/main/memory-service.ts +++ b/apps/frontend/src/main/memory-service.ts @@ -220,11 +220,11 @@ async function executeQuery( let stderr = ''; proc.stdout.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); }); proc.stderr.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('close', (code) => { @@ -362,11 +362,11 @@ async function executeSemanticQuery( let stderr = ''; proc.stdout.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); }); proc.stderr.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('close', (code) => { diff --git a/apps/frontend/src/main/project-initializer.ts b/apps/frontend/src/main/project-initializer.ts index 702ac61c..95830570 100644 --- a/apps/frontend/src/main/project-initializer.ts +++ b/apps/frontend/src/main/project-initializer.ts @@ -164,14 +164,19 @@ const GITIGNORE_ENTRIES = ['.auto-claude/']; function ensureGitignoreEntries(projectPath: string, entries: string[]): void { const gitignorePath = path.join(projectPath, '.gitignore'); + // Read existing content atomically (no TOCTOU) let content = ''; - let existingLines: string[] = []; - - if (existsSync(gitignorePath)) { + let fileExists = false; + try { content = readFileSync(gitignorePath, 'utf-8'); - existingLines = content.split('\n').map(line => line.trim()); + fileExists = true; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + // File doesn't exist - content stays empty } + const existingLines = content ? content.split('\n').map(line => line.trim()) : []; + // Find entries that need to be added const entriesToAdd: string[] = []; for (const entry of entries) { @@ -191,23 +196,23 @@ function ensureGitignoreEntries(projectPath: string, entries: string[]): void { return; } - // Build the content to append - let appendContent = ''; + if (fileExists) { + // Build the content to append + let appendContent = ''; - // Ensure file ends with newline before adding our entries - if (content && !content.endsWith('\n')) { - appendContent += '\n'; - } + // Ensure file ends with newline before adding our entries + if (content && !content.endsWith('\n')) { + appendContent += '\n'; + } - appendContent += '\n# Auto Claude data directory\n'; - for (const entry of entriesToAdd) { - appendContent += entry + '\n'; - } + appendContent += '\n# Auto Claude data directory\n'; + for (const entry of entriesToAdd) { + appendContent += entry + '\n'; + } - if (existsSync(gitignorePath)) { appendFileSync(gitignorePath, appendContent); } else { - writeFileSync(gitignorePath, '# Auto Claude data directory\n' + entriesToAdd.join('\n') + '\n'); + writeFileSync(gitignorePath, '# Auto Claude data directory\n' + entriesToAdd.join('\n') + '\n', 'utf-8'); } debug('Added entries to .gitignore', { entries: entriesToAdd }); @@ -315,7 +320,7 @@ export function initializeProject(projectPath: string): InitializationResult { const dirPath = path.join(dotAutoBuildPath, dataDir); debug('Creating data directory', { dataDir, dirPath }); mkdirSync(dirPath, { recursive: true }); - writeFileSync(path.join(dirPath, '.gitkeep'), ''); + writeFileSync(path.join(dirPath, '.gitkeep'), '', 'utf-8'); } // Update .gitignore to exclude .auto-claude/ @@ -353,7 +358,7 @@ export function ensureDataDirectories(projectPath: string): InitializationResult if (!existsSync(dirPath)) { debug('Creating missing data directory', { dataDir, dirPath }); mkdirSync(dirPath, { recursive: true }); - writeFileSync(path.join(dirPath, '.gitkeep'), ''); + writeFileSync(path.join(dirPath, '.gitkeep'), '', 'utf-8'); } } return { success: true }; diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index 497ee70c..80c7a82a 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -74,7 +74,7 @@ export class ProjectStore { * Save store to disk */ private save(): void { - writeFileSync(this.storePath, JSON.stringify(this.data, null, 2)); + writeFileSync(this.storePath, JSON.stringify(this.data, null, 2), 'utf-8'); } /** @@ -705,7 +705,7 @@ export class ProjectStore { metadata.archivedInVersion = version; } - writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); } catch (error) { console.error(`[ProjectStore] archiveTasks: Failed to archive task ${taskId} at ${specPath}:`, error); hasErrors = true; @@ -763,7 +763,7 @@ export class ProjectStore { delete metadata.archivedAt; delete metadata.archivedInVersion; - writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); } catch (error) { console.error(`[ProjectStore] unarchiveTasks: Failed to unarchive task ${taskId} at ${specPath}:`, error); hasErrors = true; diff --git a/apps/frontend/src/main/python-detector.ts b/apps/frontend/src/main/python-detector.ts index 17fd2501..fd81c7ea 100644 --- a/apps/frontend/src/main/python-detector.ts +++ b/apps/frontend/src/main/python-detector.ts @@ -117,7 +117,7 @@ function getPythonVersion(pythonCmd: string): string | null { stdio: 'pipe', timeout: 5000, windowsHide: true - }).toString().trim(); + }).toString('utf-8').trim(); // Extract version number from "Python 3.10.5" format const match = version.match(/Python (\d+\.\d+\.\d+)/); @@ -352,7 +352,7 @@ function verifyIsPython(pythonCmd: string): boolean { timeout: 5000, windowsHide: true, shell: false - }).toString().trim(); + }).toString('utf-8').trim(); // Must output "Python X.Y.Z" return /^Python \d+\.\d+/.test(output); diff --git a/apps/frontend/src/main/python-env-manager.ts b/apps/frontend/src/main/python-env-manager.ts index 21a03a24..d7f91c1b 100644 --- a/apps/frontend/src/main/python-env-manager.ts +++ b/apps/frontend/src/main/python-env-manager.ts @@ -218,7 +218,8 @@ if sys.version_info >= (3, 12): `; execSync(`"${venvPython}" -c "${checkScript.replace(/\n/g, '; ').replace(/; ; /g, '; ')}"`, { stdio: 'pipe', - timeout: 15000 + timeout: 15000, + encoding: 'utf-8' }); return true; } catch { @@ -249,8 +250,9 @@ if sys.version_info >= (3, 12): // For commands like "py -3", we need to resolve to the actual executable const pythonPath = execSync(`${pythonCmd} -c "import sys; print(sys.executable)"`, { stdio: 'pipe', - timeout: 5000 - }).toString().trim(); + timeout: 5000, + encoding: 'utf-8' + }).trim(); console.log(`[PythonEnvManager] Found Python at: ${pythonPath}`); return pythonPath; @@ -287,7 +289,8 @@ if sys.version_info >= (3, 12): return new Promise((resolve) => { const proc = spawn(systemPython, ['-m', 'venv', venvPath], { cwd: this.autoBuildSourcePath!, - stdio: 'pipe' + stdio: 'pipe', + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } }); // Track the process for cleanup on app exit @@ -313,7 +316,7 @@ if sys.version_info >= (3, 12): }, PythonEnvManager.VENV_CREATION_TIMEOUT_MS); proc.stderr?.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('close', (code) => { @@ -358,12 +361,13 @@ if sys.version_info >= (3, 12): return new Promise((resolve) => { const proc = spawn(venvPython, ['-m', 'ensurepip'], { cwd: this.autoBuildSourcePath!, - stdio: 'pipe' + stdio: 'pipe', + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } }); let stderr = ''; proc.stderr?.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('close', (code) => { @@ -412,16 +416,17 @@ if sys.version_info >= (3, 12): // Use python -m pip for better compatibility across Python versions const proc = spawn(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], { cwd: this.autoBuildSourcePath!, - stdio: 'pipe' + stdio: 'pipe', + env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } }); let stdout = ''; let stderr = ''; proc.stdout?.on('data', (data) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); // Emit progress updates for long-running installations - const lines = data.toString().split('\n'); + const lines = data.toString('utf-8').split('\n'); for (const line of lines) { if (line.includes('Installing') || line.includes('Successfully')) { this.emit('status', line.trim()); @@ -430,7 +435,7 @@ if sys.version_info >= (3, 12): }); proc.stderr?.on('data', (data) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); proc.on('close', (code) => { @@ -750,6 +755,7 @@ if sys.version_info >= (3, 12): PYTHONDONTWRITEBYTECODE: '1', // Use UTF-8 encoding PYTHONIOENCODING: 'utf-8', + PYTHONUTF8: '1', // Disable user site-packages to avoid conflicts PYTHONNOUSERSITE: '1', // Override PYTHONPATH if we have bundled packages diff --git a/apps/frontend/src/main/release-service.ts b/apps/frontend/src/main/release-service.ts index 76a9a95b..be61f94b 100644 --- a/apps/frontend/src/main/release-service.ts +++ b/apps/frontend/src/main/release-service.ts @@ -553,17 +553,21 @@ export class ReleaseService extends EventEmitter { }); const pkgPath = path.join(projectPath, 'package.json'); - if (!existsSync(pkgPath)) { - throw new Error('package.json not found in project root'); + let pkgContent: string; + try { + pkgContent = readFileSync(pkgPath, 'utf-8'); + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('package.json not found in project root'); + } + throw readErr; } - - const pkgContent = readFileSync(pkgPath, 'utf-8'); const pkg = JSON.parse(pkgContent); pkg.version = version; // Preserve formatting (detect indent) const indent = pkgContent.match(/^(\s+)/m)?.[1] || ' '; - writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n'); + writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n', 'utf-8'); // Stage and commit only package.json this.emitProgress(projectId, { @@ -719,11 +723,11 @@ export class ReleaseService extends EventEmitter { let stderr = ''; child.stdout?.on('data', (data: Buffer) => { - stdout += data.toString(); + stdout += data.toString('utf-8'); }); child.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); + stderr += data.toString('utf-8'); }); child.on('exit', (code) => { diff --git a/apps/frontend/src/main/terminal-name-generator.ts b/apps/frontend/src/main/terminal-name-generator.ts index 1c39a4d0..39337be6 100644 --- a/apps/frontend/src/main/terminal-name-generator.ts +++ b/apps/frontend/src/main/terminal-name-generator.ts @@ -199,11 +199,11 @@ export class TerminalNameGenerator extends EventEmitter { }, 30000); // 30 second timeout childProcess.stdout?.on('data', (data: Buffer) => { - output += data.toString(); + output += data.toString('utf-8'); }); childProcess.stderr?.on('data', (data: Buffer) => { - errorOutput += data.toString(); + errorOutput += data.toString('utf-8'); }); childProcess.on('exit', (code: number | null) => { diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts index 26afbbcd..3dcc598b 100644 --- a/apps/frontend/src/main/terminal-session-store.ts +++ b/apps/frontend/src/main/terminal-session-store.ts @@ -140,7 +140,7 @@ export class TerminalSessionStore { console.warn('[TerminalSessionStore] Successfully recovered from backup!'); // Immediately save the recovered data to main file try { - writeFileSync(this.storePath, JSON.stringify(backupResult.data, null, 2)); + writeFileSync(this.storePath, JSON.stringify(backupResult.data, null, 2), 'utf-8'); console.warn('[TerminalSessionStore] Restored main file from backup'); } catch (writeError) { console.error('[TerminalSessionStore] Failed to restore main file:', writeError); @@ -201,7 +201,7 @@ export class TerminalSessionStore { const content = JSON.stringify(this.data, null, 2); // Step 1: Write to temp file - writeFileSync(this.tempPath, content); + writeFileSync(this.tempPath, content, 'utf-8'); // Step 2: Rotate current file to backup (if it exists and is valid) if (existsSync(this.storePath)) { @@ -267,7 +267,7 @@ export class TerminalSessionStore { const content = JSON.stringify(this.data, null, 2); // Step 1: Write to temp file - await fsPromises.writeFile(this.tempPath, content); + await fsPromises.writeFile(this.tempPath, content, 'utf-8'); // Step 2: Rotate current file to backup (if it exists and is valid) if (await this.fileExists(this.storePath)) { diff --git a/apps/frontend/src/main/terminal/pty-daemon-client.ts b/apps/frontend/src/main/terminal/pty-daemon-client.ts index 9569ddb3..3566fe56 100644 --- a/apps/frontend/src/main/terminal/pty-daemon-client.ts +++ b/apps/frontend/src/main/terminal/pty-daemon-client.ts @@ -154,7 +154,7 @@ class PtyDaemonClient { if (!this.socket) return; this.socket.on('data', (chunk) => { - this.buffer += chunk.toString(); + this.buffer += chunk.toString('utf-8'); // Handle newline-delimited JSON const lines = this.buffer.split('\n'); diff --git a/apps/frontend/src/main/terminal/pty-daemon.ts b/apps/frontend/src/main/terminal/pty-daemon.ts index d32368bc..9851df67 100644 --- a/apps/frontend/src/main/terminal/pty-daemon.ts +++ b/apps/frontend/src/main/terminal/pty-daemon.ts @@ -130,7 +130,7 @@ class PtyDaemon { let buffer = ''; socket.on('data', (chunk) => { - buffer += chunk.toString(); + buffer += chunk.toString('utf-8'); // Handle newline-delimited JSON messages const lines = buffer.split('\n'); diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index e0cfb365..29545cdc 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -201,11 +201,11 @@ export class TitleGenerator extends EventEmitter { }, 60000); // 60 second timeout for SDK initialization + API call childProcess.stdout?.on('data', (data: Buffer) => { - output += data.toString(); + output += data.toString('utf-8'); }); childProcess.stderr?.on('data', (data: Buffer) => { - errorOutput += data.toString(); + errorOutput += data.toString('utf-8'); }); childProcess.on('exit', (code: number | null) => {