Skip to content

Commit b326fad

Browse files
authored
fix(fork-sync) improve arg escaping (#4076)
* fix(fork-sync): improve arg escaping * docs(changeset): Improve arg escaping * format code
1 parent b85a882 commit b326fad

11 files changed

Lines changed: 128 additions & 77 deletions

File tree

.changeset/chilly-mugs-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rnx-kit/fork-sync": patch
3+
---
4+
5+
Improve arg escaping

incubator/fork-sync/src/ai-merge.ts

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
} from "./modules/fs.ts";
5454
import { GitRepo } from "./modules/git.ts";
5555
import * as proc from "./modules/proc.ts";
56-
const { exec } = proc;
56+
const { exec, shellVar } = proc;
5757

5858
// Import claude module
5959
import { 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
*/
467471
function 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
*/
559570
function 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");

incubator/fork-sync/src/modules/ai-prompt-template.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
import * as fs from "node:fs/promises";
3939
import * as path from "node:path";
40-
import { exists } from "./fs.ts";
40+
import { exists, normalizePath } from "./fs.ts";
4141

4242
// =============================================================================
4343
// Types
@@ -372,9 +372,9 @@ export async function collectSyncInstructions(
372372
});
373373

374374
// Add with source path comment for combined output
375-
const relativePath = path
376-
.relative(repoRoot, candidatePath)
377-
.replace(/\\/g, "/");
375+
const relativePath = normalizePath(
376+
path.relative(repoRoot, candidatePath)
377+
);
378378
combinedParts.push(`<!-- From: ${relativePath} -->\n${content}`);
379379
}
380380
}

incubator/fork-sync/src/modules/claude.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
* @module claude
3636
*/
3737

38-
import { exec, ExecError } from "./proc.ts";
38+
import { exec, ExecError, shellVar } from "./proc.ts";
3939

4040
// =============================================================================
4141
// Types
@@ -308,14 +308,10 @@ export async function invokeClaudeReadOnly(
308308
const cwd = options.cwd ?? process.cwd();
309309
const verbose = options.verbose ?? false;
310310

311-
// Build meta-prompt that references the prompt file
312-
const metaPrompt = `Read and follow the prompt in ${options.promptFile}`;
313-
314311
// Build command string for shell execution
315312
// Note: -p is required for non-interactive mode, --verbose is required when using --output-format stream-json
316313
// IMPORTANT: The prompt MUST come BEFORE --allowedTools because --allowedTools is variadic
317314
// and will consume all following arguments as tool names
318-
// Quote arguments that may contain spaces
319315
const cmdParts = [
320316
"claude",
321317
"-p",
@@ -328,8 +324,12 @@ export async function invokeClaudeReadOnly(
328324
cmdParts.push("--model", options.model.trim());
329325
}
330326

331-
// Add meta-prompt BEFORE --allowedTools (variadic option) - quote it for shell
332-
cmdParts.push(`"${metaPrompt}"`);
327+
// Meta-prompt references the prompt file via env var to avoid shell escaping issues
328+
const metaPrompt = `Read and follow the prompt in ${options.promptFile}`;
329+
const shellEnv: Record<string, string> = { CLAUDE_PROMPT: metaPrompt };
330+
331+
// Add meta-prompt BEFORE --allowedTools (variadic option)
332+
cmdParts.push(shellVar("CLAUDE_PROMPT"));
333333

334334
// --allowedTools MUST be last as it consumes all following arguments
335335
cmdParts.push("--allowedTools", allowedTools.join(","));
@@ -348,7 +348,7 @@ export async function invokeClaudeReadOnly(
348348
try {
349349
for await (const chunk of exec(command, {
350350
cwd,
351-
env: options.env,
351+
env: { ...options.env, ...shellEnv },
352352
timeout,
353353
mode: "lines",
354354
})) {

incubator/fork-sync/src/modules/copilot.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4-
import { exec, ExecError } from "./proc.ts";
4+
import { exec, ExecError, shellVar } from "./proc.ts";
55

66
export interface CopilotInvokeOptions {
77
promptFile: string; // Path to prompt file (CLI will read it)
@@ -64,20 +64,21 @@ export async function invokeCopilotReadOnly(
6464
};
6565
}
6666

67-
// Build meta-prompt that references the prompt file
67+
// Build meta-prompt — passed via env var to avoid shell escaping issues
6868
const metaPrompt = `Read and follow the prompt in ${options.promptFile}`;
69+
const shellEnv: Record<string, string> = { COPILOT_PROMPT: metaPrompt };
6970

7071
const allowedTools = options.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
7172

7273
// Build command string for shell execution
73-
// Quote arguments that contain spaces
74-
const cmdParts = ["copilot", "-p", `"${metaPrompt}"`];
74+
const cmdParts = ["copilot", "-p", shellVar("COPILOT_PROMPT")];
7575
for (const tool of allowedTools) {
7676
cmdParts.push("--allow-tool", tool);
7777
}
7878
cmdParts.push("--silent");
7979
if (options.cwd) {
80-
cmdParts.push("--add-dir", `"${options.cwd}"`);
80+
shellEnv.COPILOT_CWD = options.cwd;
81+
cmdParts.push("--add-dir", shellVar("COPILOT_CWD"));
8182
}
8283

8384
if (options.model && options.model.trim().length > 0) {
@@ -102,7 +103,7 @@ export async function invokeCopilotReadOnly(
102103
try {
103104
for await (const chunk of exec(command, {
104105
cwd: options.cwd,
105-
env: options.env,
106+
env: { ...options.env, ...shellEnv },
106107
timeout,
107108
mode: "lines",
108109
})) {

incubator/fork-sync/src/modules/fs.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
/**
55
* Filesystem utility functions.
66
*
7-
* Common async filesystem operations with proper error handling:
7+
* Common filesystem operations with proper error handling:
8+
* - Path normalization (normalizePath)
89
* - Existence checks (exists, isFile, isDirectory)
910
* - Directory management (ensureDir, removeDir)
1011
* - Content hashing with CRLF normalization (hashFileContent)
@@ -17,6 +18,15 @@ import * as fs from "node:fs/promises";
1718
import * as path from "node:path";
1819
import * as parallel from "./parallel.ts";
1920

21+
// =============================================================================
22+
// Path Utilities
23+
// =============================================================================
24+
25+
/** Normalize path separators to forward slashes (for Git and cross-platform compatibility). */
26+
export function normalizePath(filePath: string): string {
27+
return filePath.replace(/\\/g, "/");
28+
}
29+
2030
// =============================================================================
2131
// Existence Checks
2232
// =============================================================================

incubator/fork-sync/src/modules/git.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
*/
2929

3030
import * as path from "node:path";
31-
import { exists } from "./fs.ts";
31+
import { exists, normalizePath } from "./fs.ts";
3232
import { spawn, type OutputChunk } from "./proc.ts";
3333

3434
// =============================================================================
@@ -57,11 +57,6 @@ export interface MergeTool {
5757
// Path Utilities
5858
// =============================================================================
5959

60-
/** Normalize path separators to forward slashes (for Git compatibility on Windows). */
61-
export function normalizePath(filePath: string): string {
62-
return filePath.replace(/\\/g, "/");
63-
}
64-
6560
function stripPrefix(filePath: string, prefix?: string): string | null {
6661
if (!prefix) {
6762
return filePath;

0 commit comments

Comments
 (0)