@@ -52,6 +52,114 @@ import { SymphonyError } from '../../../shared/symphony-types';
5252
5353const LOG_CONTEXT = '[Symphony]' ;
5454
55+ // ============================================================================
56+ // Validation Helpers
57+ // ============================================================================
58+
59+ /**
60+ * Sanitize repository name to prevent path traversal attacks.
61+ * Removes any characters that could be used for path traversal.
62+ */
63+ function sanitizeRepoName ( repoName : string ) : string {
64+ // Only allow alphanumeric, dashes, underscores, and dots (not leading)
65+ return repoName
66+ . replace ( / \. \. / g, '' ) // Remove path traversal sequences
67+ . replace ( / [ ^ a - z A - Z 0 - 9 _ \- ] / g, '-' ) // Replace unsafe chars with dashes
68+ . replace ( / ^ \. + / , '' ) // Remove leading dots
69+ . substring ( 0 , 100 ) ; // Limit length
70+ }
71+
72+ /**
73+ * Validate that a URL is a GitHub repository URL.
74+ * Only allows HTTPS URLs to github.com.
75+ */
76+ function validateGitHubUrl ( url : string ) : { valid : boolean ; error ?: string } {
77+ try {
78+ const parsed = new URL ( url ) ;
79+ if ( parsed . protocol !== 'https:' ) {
80+ return { valid : false , error : 'Only HTTPS URLs are allowed' } ;
81+ }
82+ if ( parsed . hostname !== 'github.com' && parsed . hostname !== 'www.github.com' ) {
83+ return { valid : false , error : 'Only GitHub repositories are allowed' } ;
84+ }
85+ // Check for valid repo path format (owner/repo)
86+ const pathParts = parsed . pathname . split ( '/' ) . filter ( Boolean ) ;
87+ if ( pathParts . length < 2 ) {
88+ return { valid : false , error : 'Invalid repository path' } ;
89+ }
90+ return { valid : true } ;
91+ } catch {
92+ return { valid : false , error : 'Invalid URL format' } ;
93+ }
94+ }
95+
96+ /**
97+ * Validate repository slug format (owner/repo).
98+ */
99+ function validateRepoSlug ( slug : string ) : { valid : boolean ; error ?: string } {
100+ if ( ! slug || typeof slug !== 'string' ) {
101+ return { valid : false , error : 'Repository slug is required' } ;
102+ }
103+ const parts = slug . split ( '/' ) ;
104+ if ( parts . length !== 2 ) {
105+ return { valid : false , error : 'Invalid repository slug format (expected owner/repo)' } ;
106+ }
107+ const [ owner , repo ] = parts ;
108+ if ( ! owner || ! repo ) {
109+ return { valid : false , error : 'Owner and repository name are required' } ;
110+ }
111+ // GitHub username/repo name rules
112+ if ( ! / ^ [ a - z A - Z 0 - 9 ] ( [ a - z A - Z 0 - 9 - ] * [ a - z A - Z 0 - 9 ] ) ? $ / . test ( owner ) ) {
113+ return { valid : false , error : 'Invalid owner name' } ;
114+ }
115+ if ( ! / ^ [ a - z A - Z 0 - 9 . _ - ] + $ / . test ( repo ) ) {
116+ return { valid : false , error : 'Invalid repository name' } ;
117+ }
118+ return { valid : true } ;
119+ }
120+
121+ /**
122+ * Validate contribution start parameters.
123+ */
124+ function validateContributionParams ( params : {
125+ repoSlug : string ;
126+ repoUrl : string ;
127+ repoName : string ;
128+ issueNumber : number ;
129+ documentPaths : string [ ] ;
130+ } ) : { valid : boolean ; error ?: string } {
131+ // Validate repo slug
132+ const slugValidation = validateRepoSlug ( params . repoSlug ) ;
133+ if ( ! slugValidation . valid ) {
134+ return slugValidation ;
135+ }
136+
137+ // Validate URL
138+ const urlValidation = validateGitHubUrl ( params . repoUrl ) ;
139+ if ( ! urlValidation . valid ) {
140+ return urlValidation ;
141+ }
142+
143+ // Validate repo name
144+ if ( ! params . repoName || typeof params . repoName !== 'string' ) {
145+ return { valid : false , error : 'Repository name is required' } ;
146+ }
147+
148+ // Validate issue number
149+ if ( ! Number . isInteger ( params . issueNumber ) || params . issueNumber <= 0 ) {
150+ return { valid : false , error : 'Invalid issue number' } ;
151+ }
152+
153+ // Validate document paths (check for path traversal)
154+ for ( const docPath of params . documentPaths ) {
155+ if ( docPath . includes ( '..' ) || docPath . startsWith ( '/' ) ) {
156+ return { valid : false , error : `Invalid document path: ${ docPath } ` } ;
157+ }
158+ }
159+
160+ return { valid : true } ;
161+ }
162+
55163// ============================================================================
56164// Dependencies Interface
57165// ============================================================================
@@ -331,6 +439,53 @@ async function createBranch(
331439 return { success : true } ;
332440}
333441
442+ /**
443+ * Check if gh CLI is authenticated.
444+ */
445+ async function checkGhAuthentication ( ) : Promise < { authenticated : boolean ; error ?: string } > {
446+ const result = await execFileNoThrow ( 'gh' , [ 'auth' , 'status' ] ) ;
447+ if ( result . exitCode !== 0 ) {
448+ // gh auth status outputs to stderr even on success for some info
449+ const output = result . stderr + result . stdout ;
450+ if ( output . includes ( 'not logged in' ) || output . includes ( 'no accounts' ) ) {
451+ return { authenticated : false , error : 'GitHub CLI is not authenticated. Run "gh auth login" to authenticate.' } ;
452+ }
453+ // If gh CLI is not installed
454+ if ( output . includes ( 'command not found' ) || output . includes ( 'not recognized' ) ) {
455+ return { authenticated : false , error : 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/' } ;
456+ }
457+ return { authenticated : false , error : `GitHub CLI error: ${ output } ` } ;
458+ }
459+ return { authenticated : true } ;
460+ }
461+
462+ /**
463+ * Get the default branch of a repository.
464+ */
465+ async function getDefaultBranch ( repoPath : string ) : Promise < string > {
466+ // Try to get the default branch from remote
467+ const result = await execFileNoThrow ( 'git' , [ 'symbolic-ref' , 'refs/remotes/origin/HEAD' ] , repoPath ) ;
468+ if ( result . exitCode === 0 ) {
469+ // Output is like "refs/remotes/origin/main"
470+ const branch = result . stdout . trim ( ) . replace ( 'refs/remotes/origin/' , '' ) ;
471+ if ( branch ) return branch ;
472+ }
473+
474+ // Fallback: try common branch names
475+ const checkResult = await execFileNoThrow ( 'git' , [ 'ls-remote' , '--heads' , 'origin' , 'main' ] , repoPath ) ;
476+ if ( checkResult . exitCode === 0 && checkResult . stdout . includes ( 'refs/heads/main' ) ) {
477+ return 'main' ;
478+ }
479+
480+ const masterCheck = await execFileNoThrow ( 'git' , [ 'ls-remote' , '--heads' , 'origin' , 'master' ] , repoPath ) ;
481+ if ( masterCheck . exitCode === 0 && masterCheck . stdout . includes ( 'refs/heads/master' ) ) {
482+ return 'master' ;
483+ }
484+
485+ // Default to main if we can't determine
486+ return 'main' ;
487+ }
488+
334489/**
335490 * Push branch and create draft PR using gh CLI.
336491 */
@@ -340,6 +495,12 @@ async function createDraftPR(
340495 title : string ,
341496 body : string
342497) : Promise < { success : boolean ; prUrl ?: string ; prNumber ?: number ; error ?: string } > {
498+ // Check gh authentication first
499+ const authCheck = await checkGhAuthentication ( ) ;
500+ if ( ! authCheck . authenticated ) {
501+ return { success : false , error : authCheck . error } ;
502+ }
503+
343504 // First push the branch
344505 const pushResult = await execFileNoThrow ( 'git' , [ 'push' , '-u' , 'origin' , 'HEAD' ] , repoPath ) ;
345506
@@ -355,6 +516,9 @@ async function createDraftPR(
355516 ) ;
356517
357518 if ( prResult . exitCode !== 0 ) {
519+ // If PR creation failed after push, try to delete the remote branch
520+ logger . warn ( 'PR creation failed, attempting to clean up remote branch' , LOG_CONTEXT ) ;
521+ await execFileNoThrow ( 'git' , [ 'push' , 'origin' , '--delete' , 'HEAD' ] , repoPath ) ;
358522 return { success : false , error : `Failed to create PR: ${ prResult . stderr } ` } ;
359523 }
360524
@@ -592,6 +756,24 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
592756 sessionId : string ;
593757 baseBranch ?: string ;
594758 } ) : Promise < Omit < StartContributionResponse , 'success' > > => {
759+ // Validate input parameters
760+ const validation = validateContributionParams ( {
761+ repoSlug : params . repoSlug ,
762+ repoUrl : params . repoUrl ,
763+ repoName : params . repoName ,
764+ issueNumber : params . issueNumber ,
765+ documentPaths : params . documentPaths ,
766+ } ) ;
767+ if ( ! validation . valid ) {
768+ return { error : validation . error } ;
769+ }
770+
771+ // Check gh CLI authentication before starting
772+ const authCheck = await checkGhAuthentication ( ) ;
773+ if ( ! authCheck . authenticated ) {
774+ return { error : authCheck . error } ;
775+ }
776+
595777 const {
596778 repoSlug,
597779 repoUrl,
@@ -601,7 +783,6 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
601783 documentPaths,
602784 agentType,
603785 sessionId,
604- baseBranch = 'main' ,
605786 } = params ;
606787
607788 const contributionId = generateContributionId ( ) ;
@@ -617,10 +798,13 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
617798 } ;
618799 }
619800
801+ // Sanitize repo name for local path
802+ const sanitizedRepoName = sanitizeRepoName ( repoName ) ;
803+
620804 // Determine local path
621805 const reposDir = getReposDir ( app ) ;
622806 await fs . mkdir ( reposDir , { recursive : true } ) ;
623- const localPath = path . join ( reposDir , `${ repoName } -${ contributionId } ` ) ;
807+ const localPath = path . join ( reposDir , `${ sanitizedRepoName } -${ contributionId } ` ) ;
624808
625809 // Generate branch name
626810 const branchName = generateBranchName ( issueNumber ) ;
@@ -631,6 +815,9 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
631815 return { error : `Clone failed: ${ cloneResult . error } ` } ;
632816 }
633817
818+ // Detect default branch (don't rely on hardcoded 'main')
819+ const baseBranch = params . baseBranch || await getDefaultBranch ( localPath ) ;
820+
634821 // Create branch
635822 const branchResult = await createBranch ( localPath , branchName ) ;
636823 if ( ! branchResult . success ) {
@@ -927,6 +1114,12 @@ This PR will be updated automatically when the Auto Run completes.`;
9271114 async ( params : { repoUrl : string ; localPath : string } ) : Promise < { success : boolean ; error ?: string } > => {
9281115 const { repoUrl, localPath } = params ;
9291116
1117+ // Validate GitHub URL
1118+ const urlValidation = validateGitHubUrl ( repoUrl ) ;
1119+ if ( ! urlValidation . valid ) {
1120+ return { success : false , error : urlValidation . error } ;
1121+ }
1122+
9301123 // Ensure parent directory exists
9311124 const parentDir = path . dirname ( localPath ) ;
9321125 await fs . mkdir ( parentDir , { recursive : true } ) ;
@@ -967,57 +1160,98 @@ This PR will be updated automatically when the Auto Run completes.`;
9671160 autoRunPath ?: string ;
9681161 error ?: string ;
9691162 } > => {
970- const { contributionId, sessionId, repoSlug : _repoSlug , issueNumber, issueTitle, localPath, documentPaths } = params ;
1163+ const { contributionId, sessionId, repoSlug, issueNumber, issueTitle, localPath, documentPaths } = params ;
1164+
1165+ // Validate inputs
1166+ const slugValidation = validateRepoSlug ( repoSlug ) ;
1167+ if ( ! slugValidation . valid ) {
1168+ return { success : false , error : slugValidation . error } ;
1169+ }
1170+
1171+ if ( ! Number . isInteger ( issueNumber ) || issueNumber <= 0 ) {
1172+ return { success : false , error : 'Invalid issue number' } ;
1173+ }
1174+
1175+ // Validate document paths for path traversal
1176+ for ( const docPath of documentPaths ) {
1177+ if ( docPath . includes ( '..' ) || docPath . startsWith ( '/' ) ) {
1178+ return { success : false , error : `Invalid document path: ${ docPath } ` } ;
1179+ }
1180+ }
1181+
1182+ // Check gh CLI authentication
1183+ const authCheck = await checkGhAuthentication ( ) ;
1184+ if ( ! authCheck . authenticated ) {
1185+ return { success : false , error : authCheck . error } ;
1186+ }
9711187
9721188 try {
9731189 // 1. Create branch
9741190 const branchName = generateBranchName ( issueNumber ) ;
9751191 const branchResult = await createBranch ( localPath , branchName ) ;
9761192 if ( ! branchResult . success ) {
977- return { success : false , error : 'Failed to create branch' } ;
1193+ logger . error ( 'Failed to create branch' , LOG_CONTEXT , { localPath, branchName, error : branchResult . error } ) ;
1194+ return { success : false , error : `Failed to create branch: ${ branchResult . error } ` } ;
9781195 }
9791196
9801197 // 2. Empty commit to enable push
9811198 const commitMessage = `[Symphony] Start contribution for #${ issueNumber } ` ;
982- await execFileNoThrow ( 'git' , [ 'commit' , '--allow-empty' , '-m' , commitMessage ] , localPath ) ;
1199+ const commitResult = await execFileNoThrow ( 'git' , [ 'commit' , '--allow-empty' , '-m' , commitMessage ] , localPath ) ;
1200+ if ( commitResult . exitCode !== 0 ) {
1201+ logger . error ( 'Failed to create empty commit' , LOG_CONTEXT , { localPath, error : commitResult . stderr } ) ;
1202+ }
9831203
9841204 // 3. Push branch
9851205 const pushResult = await execFileNoThrow ( 'git' , [ 'push' , '-u' , 'origin' , branchName ] , localPath ) ;
9861206 if ( pushResult . exitCode !== 0 ) {
987- return { success : false , error : 'Failed to push branch' } ;
1207+ logger . error ( 'Failed to push branch' , LOG_CONTEXT , { localPath, branchName, error : pushResult . stderr } ) ;
1208+ return { success : false , error : `Failed to push branch: ${ pushResult . stderr } ` } ;
9881209 }
9891210
990- // 4. Create draft PR
1211+ // 4. Detect default branch for PR base
1212+ const baseBranch = await getDefaultBranch ( localPath ) ;
1213+
1214+ // 5. Create draft PR
9911215 const prTitle = `[WIP] Symphony: ${ issueTitle } ` ;
9921216 const prBody = `## Symphony Contribution\n\nCloses #${ issueNumber } \n\n*Work in progress via Maestro Symphony*` ;
9931217 const prResult = await execFileNoThrow (
9941218 'gh' ,
995- [ 'pr' , 'create' , '--draft' , '--title' , prTitle , '--body' , prBody ] ,
1219+ [ 'pr' , 'create' , '--draft' , '--base' , baseBranch , '-- title', prTitle , '--body' , prBody ] ,
9961220 localPath
9971221 ) ;
9981222 if ( prResult . exitCode !== 0 ) {
999- return { success : false , error : 'Failed to create draft PR' } ;
1223+ // Attempt to clean up the remote branch
1224+ logger . warn ( 'PR creation failed, cleaning up remote branch' , LOG_CONTEXT , { branchName } ) ;
1225+ await execFileNoThrow ( 'git' , [ 'push' , 'origin' , '--delete' , branchName ] , localPath ) ;
1226+ logger . error ( 'Failed to create draft PR' , LOG_CONTEXT , { localPath, error : prResult . stderr } ) ;
1227+ return { success : false , error : `Failed to create draft PR: ${ prResult . stderr } ` } ;
10001228 }
10011229
10021230 const prUrl = prResult . stdout . trim ( ) ;
10031231 const prNumberMatch = prUrl . match ( / \/ p u l l \/ ( \d + ) / ) ;
10041232 const prNumber = prNumberMatch ? parseInt ( prNumberMatch [ 1 ] , 10 ) : 0 ;
10051233
1006- // 5 . Copy Auto Run documents to local folder
1234+ // 6 . Copy Auto Run documents to local folder
10071235 const autoRunDir = path . join ( localPath , 'Auto Run Docs' ) ;
10081236 await fs . mkdir ( autoRunDir , { recursive : true } ) ;
10091237
10101238 for ( const docPath of documentPaths ) {
1011- const sourcePath = path . join ( localPath , docPath ) ;
1239+ // Ensure the path doesn't escape the localPath
1240+ const resolvedSource = path . resolve ( localPath , docPath ) ;
1241+ if ( ! resolvedSource . startsWith ( localPath ) ) {
1242+ logger . error ( 'Attempted path traversal in document copy' , LOG_CONTEXT , { docPath } ) ;
1243+ continue ;
1244+ }
10121245 const destPath = path . join ( autoRunDir , path . basename ( docPath ) ) ;
10131246 try {
1014- await fs . copyFile ( sourcePath , destPath ) ;
1247+ await fs . copyFile ( resolvedSource , destPath ) ;
1248+ logger . info ( 'Copied document' , LOG_CONTEXT , { from : resolvedSource , to : destPath } ) ;
10151249 } catch ( e ) {
1016- logger . warn ( 'Failed to copy document' , LOG_CONTEXT , { docPath, error : e } ) ;
1250+ logger . warn ( 'Failed to copy document' , LOG_CONTEXT , { docPath, error : e instanceof Error ? e . message : String ( e ) } ) ;
10171251 }
10181252 }
10191253
1020- // 6 . Broadcast status update
1254+ // 7 . Broadcast status update
10211255 const mainWindow = getMainWindow ?.( ) ;
10221256 if ( mainWindow && ! mainWindow . isDestroyed ( ) ) {
10231257 mainWindow . webContents . send ( 'symphony:contributionStarted' , {
0 commit comments