@@ -53,7 +53,7 @@ import {
5353} from "./modules/fs.ts" ;
5454import { GitRepo } from "./modules/git.ts" ;
5555import * as proc from "./modules/proc.ts" ;
56- const { exec } = proc ;
56+ const { exec, shellVar } = proc ;
5757
5858// Import claude module
5959import { invokeClaudeReadOnly } from "./modules/claude.ts" ;
@@ -456,17 +456,22 @@ let args!: ParsedArgs;
456456// Utility Functions
457457// =============================================================================
458458
459- function quoteForCommand ( value : string ) : string {
460- return `"${ value . replace ( / " / g, '\\"' ) } "` ;
459+ /**
460+ * Replace path separators and colons with underscores to produce a flat filename.
461+ */
462+ function sanitizePathForFilename ( relPath : string ) : string {
463+ return relPath . replace ( / [ \\ / : / ] / g, "_" ) ;
461464}
462465
463466/**
464- * Get a safe filename from a file path by replacing separators with underscores.
467+ * Get a safe filename from an absolute file path by making it relative to
468+ * the working directory and replacing separators with underscores.
465469 * Used for both prompt and response file naming.
466470 */
467471function getSafeName ( filePath : string ) : string {
468- const relativePath = path . relative ( args . workingDirectory , filePath ) ;
469- return relativePath . replace ( / [ \\ / : / ] / g, "_" ) ;
472+ return sanitizePathForFilename (
473+ path . relative ( args . workingDirectory , filePath )
474+ ) ;
470475}
471476
472477/**
@@ -555,36 +560,49 @@ async function resolveFallbackMergeTool(): Promise<{
555560
556561/**
557562 * Apply merge tool arguments to the command.
563+ *
564+ * File paths are passed via environment variables rather than interpolated into
565+ * the command string. This avoids shell-escaping pitfalls (cmd.exe vs POSIX sh
566+ * have incompatible quoting rules) and prevents injection via crafted paths.
567+ * The shell expands the variable references safely because the values never
568+ * pass through the shell's command parser.
558569 */
559570function applyMergeToolArgs(
560571 cmd: string,
561572 base: string,
562573 local: string,
563574 remote: string,
564575 merged: string
565- ): string {
576+ ): { command: string; env: Record<string, string> } {
577+ const env: Record<string, string> = {
578+ BASE: base,
579+ LOCAL: local,
580+ REMOTE: remote,
581+ MERGED: merged,
582+ };
583+
566584 let replaced = false;
567585 let result = cmd;
568586
569- const replacements : Record<string, string> = {
570- $BASE: quoteForCommand(base) ,
571- $LOCAL: quoteForCommand(local) ,
572- $REMOTE: quoteForCommand(remote) ,
573- $MERGED: quoteForCommand(merged) ,
587+ const tokens : Record<string, string> = {
588+ $BASE: "BASE" ,
589+ $LOCAL: "LOCAL" ,
590+ $REMOTE: "REMOTE" ,
591+ $MERGED: "MERGED" ,
574592 };
575593
576- for (const [token, value ] of Object.entries(replacements )) {
594+ for (const [token, varName ] of Object.entries(tokens )) {
577595 if (result.includes(token)) {
578- result = result.split(token).join(value );
596+ result = result.split(token).join(shellVar(varName) );
579597 replaced = true;
580598 }
581599 }
582600
583601 if (!replaced) {
584- result = ` $ { result } ${quoteForCommand ( base ) } ${quoteForCommand ( local ) } ${quoteForCommand ( remote ) } - o $ { quoteForCommand ( merged ) } `;
602+ result = ` $ { result } ${shellVar ( "BASE" ) } ${shellVar ( "LOCAL" ) } ${shellVar ( "REMOTE" ) } - o $ { shellVar ( "MERGED" ) } `;
585603 }
586604
587- return result ;
605+ return { command : result , env } ;
588606}
589607
590608/**
@@ -607,12 +625,12 @@ async function launchFallbackMergeTool(
607625 }
608626
609627 info ( `Launching fallback merge tool: ${ name } ...` ) ;
610- const command = applyMergeToolArgs ( cmd , base , local , remote , merged ) ;
628+ const { command , env } = applyMergeToolArgs ( cmd , base , local , remote , merged ) ;
611629
612630 // The command is a shell command string, so we use exec (which runs through shell).
613631 // Using passthrough so child processes inherit TTY status for progress UI.
614632 // Using ignoreExitCode since merge tools may exit non-zero for various reasons.
615- await exec ( command , { mode : "passthrough" , ignoreExitCode : true } ) ;
633+ await exec ( command , { mode : "passthrough" , ignoreExitCode : true , env } ) ;
616634 return true ;
617635}
618636
@@ -1164,7 +1182,7 @@ async function extractStagesForFallback(
11641182 tempDir: string
11651183): Promise<{ base: string; local: string; remote: string }> {
11661184 const repo = new GitRepo(cwd);
1167- const safeName = relPath.replace(/[\\/:/]/g, "_" );
1185+ const safeName = sanitizePathForFilename(relPath );
11681186 const ext = path.extname(relPath);
11691187
11701188 const stages = [
@@ -1413,14 +1431,18 @@ ${skippedSection()}${unresolvedSection()}`
14131431 ctx . relativePath ,
14141432 tempDir
14151433 ) ;
1416- const command = applyMergeToolArgs (
1434+ const { command, env } = applyMergeToolArgs (
14171435 cmd ,
14181436 stages . base ,
14191437 stages . local ,
14201438 stages . remote ,
14211439 ctx . filePath
14221440 ) ;
1423- await exec ( command , { mode : "passthrough" , ignoreExitCode : true } ) ;
1441+ await exec ( command , {
1442+ mode : "passthrough" ,
1443+ ignoreExitCode : true ,
1444+ env,
1445+ } ) ;
14241446
14251447 // Check if resolved after fallback and stage if so
14261448 const postContent = await fs . readFile ( ctx . filePath , "utf-8" ) ;
0 commit comments