fix: address PR #173 security and code quality feedback

Security fixes:
- Enhanced branch name sanitization for cross-platform filesystem safety
  (handles Windows-invalid chars, reserved names, path length limits)
- Added branch name validation in pr-info.ts to prevent command injection
- Sanitized prUrl in kanban-card to only allow http/https URLs

Code quality improvements:
- Fixed placeholder issue where {owner}/{repo} was passed literally to gh api
- Replaced async forEach with Promise.all for proper async handling
- Display PR number extracted from URL in kanban cards

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Seibert
2025-12-19 20:39:38 -05:00
parent 6c25680115
commit ec7c2892c2
7 changed files with 153 additions and 132 deletions

View File

@@ -20,12 +20,38 @@ export interface WorktreeMetadata {
pr?: WorktreePRInfo; pr?: WorktreePRInfo;
} }
/**
* Sanitize branch name for cross-platform filesystem safety
*/
function sanitizeBranchName(branch: string): string {
// Replace characters that are invalid or problematic on various filesystems:
// - Forward and backslashes (path separators)
// - Windows invalid chars: : * ? " < > |
// - Other potentially problematic chars
let safeBranch = branch
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
.replace(/-+/g, "-") // Collapse multiple dashes
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
// Truncate to safe length (leave room for path components)
safeBranch = safeBranch.substring(0, 200);
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
safeBranch = `_${safeBranch || "branch"}`;
}
return safeBranch;
}
/** /**
* Get the path to the worktree metadata directory * Get the path to the worktree metadata directory
*/ */
function getWorktreeMetadataDir(projectPath: string, branch: string): string { function getWorktreeMetadataDir(projectPath: string, branch: string): string {
// Sanitize branch name for filesystem (replace / with -) const safeBranch = sanitizeBranchName(branch);
const safeBranch = branch.replace(/\//g, "-");
return path.join(projectPath, ".automaker", "worktrees", safeBranch); return path.join(projectPath, ".automaker", "worktrees", safeBranch);
} }

View File

@@ -8,6 +8,24 @@ import { promisify } from "util";
import { getErrorMessage, logError } from "../common.js"; import { getErrorMessage, logError } from "../common.js";
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js"; import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js";
// Shell escaping utility to prevent command injection
function shellEscape(arg: string): string {
if (process.platform === "win32") {
// Windows CMD shell escaping
return `"${arg.replace(/"/g, '""')}"`;
} else {
// Unix shell escaping
return `'${arg.replace(/'/g, "'\\''")}'`;
}
}
// Validate branch name to prevent command injection
function isValidBranchName(name: string): boolean {
// Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars
// Also reject shell metacharacters for safety
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250;
}
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations // Extended PATH to include common tool installation locations
@@ -78,6 +96,15 @@ export function createCreatePRHandler() {
); );
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
// Validate branch name for security
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: "Invalid branch name contains unsafe characters",
});
return;
}
// Check for uncommitted changes // Check for uncommitted changes
const { stdout: status } = await execAsync("git status --porcelain", { const { stdout: status } = await execAsync("git status --porcelain", {
cwd: worktreePath, cwd: worktreePath,

View File

@@ -42,6 +42,15 @@ const execEnv = {
PATH: extendedPath, PATH: extendedPath,
}; };
/**
* Validate branch name to prevent command injection.
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
* We also reject shell metacharacters for safety.
*/
function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250;
}
export interface PRComment { export interface PRComment {
id: number; id: number;
author: string; author: string;
@@ -79,6 +88,15 @@ export function createPRInfoHandler() {
return; return;
} }
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: "Invalid branch name contains unsafe characters",
});
return;
}
// Check if gh CLI is available // Check if gh CLI is available
let ghCliAvailable = false; let ghCliAvailable = false;
try { try {
@@ -226,10 +244,10 @@ export function createPRInfoHandler() {
// Get review comments (inline code comments) // Get review comments (inline code comments)
let reviewComments: PRComment[] = []; let reviewComments: PRComment[] = [];
// Only fetch review comments if we have repository info
if (targetRepo) {
try { try {
const reviewsEndpoint = targetRepo const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`;
? `repos/${targetRepo}/pulls/${prNumber}/comments`
: `repos/{owner}/{repo}/pulls/${prNumber}/comments`;
const reviewsCmd = `gh api ${reviewsEndpoint}`; const reviewsCmd = `gh api ${reviewsEndpoint}`;
const { stdout: reviewsOutput } = await execAsync( const { stdout: reviewsOutput } = await execAsync(
reviewsCmd, reviewsCmd,
@@ -256,6 +274,9 @@ export function createPRInfoHandler() {
} catch (error) { } catch (error) {
console.warn("[PRInfo] Failed to fetch review comments:", error); console.warn("[PRInfo] Failed to fetch review comments:", error);
} }
} else {
console.warn("[PRInfo] Cannot fetch review comments: repository info not available");
}
const prInfo: PRInfo = { const prInfo: PRInfo = {
number: prNumber, number: prNumber,

View File

@@ -421,69 +421,21 @@ export function BoardView() {
// Handler for addressing PR comments - creates a feature and starts it automatically // Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback( const handleAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => { async (worktree: WorktreeInfo, prInfo: PRInfo) => {
// If comments are empty, fetch them from GitHub // Use a simple prompt that instructs the agent to read and address PR feedback
let fullPRInfo = prInfo; // The agent will fetch the PR comments directly, which is more reliable and up-to-date
if (prInfo.comments.length === 0 && prInfo.reviewComments.length === 0) { const prNumber = prInfo.number;
try { const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
const api = getElectronAPI();
if (api?.worktree?.getPRInfo) {
const result = await api.worktree.getPRInfo(
worktree.path,
worktree.branch
);
if (
result.success &&
result.result?.hasPR &&
result.result.prInfo
) {
fullPRInfo = result.result.prInfo;
}
}
} catch (error) {
console.error("[Board] Failed to fetch PR comments:", error);
}
}
// Format PR comments into a feature description
const allComments = [
...fullPRInfo.comments.map((c) => ({
...c,
type: "comment" as const,
})),
...fullPRInfo.reviewComments.map((c) => ({
...c,
type: "review" as const,
})),
].sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
let description = `Address PR #${fullPRInfo.number} feedback: "${fullPRInfo.title}"\n\n`;
description += `PR URL: ${fullPRInfo.url}\n\n`;
if (allComments.length === 0) {
description += `No comments found on this PR yet. Check the PR for any new feedback.\n`;
} else {
description += `## Feedback to address:\n\n`;
for (const comment of allComments) {
if (comment.type === "review" && comment.path) {
description += `### ${comment.path}${comment.line ? `:${comment.line}` : ""}\n`;
}
description += `**@${comment.author}:**\n${comment.body}\n\n`;
}
}
// Create the feature // Create the feature
const featureData = { const featureData = {
category: "PR Review", category: "PR Review",
description: description.trim(), description,
steps: [], steps: [],
images: [], images: [],
imagePaths: [], imagePaths: [],
skipTests: defaultSkipTests, skipTests: defaultSkipTests,
model: "sonnet" as const, model: "opus" as const,
thinkingLevel: "medium" as const, thinkingLevel: "none" as const,
branchName: worktree.branch, branchName: worktree.branch,
priority: 1, // High priority for PR feedback priority: 1, // High priority for PR feedback
planningMode: "skip" as const, planningMode: "skip" as const,
@@ -500,7 +452,7 @@ export function BoardView() {
(f) => (f) =>
f.branchName === worktree.branch && f.branchName === worktree.branch &&
f.status === "backlog" && f.status === "backlog" &&
f.description.includes(`PR #${fullPRInfo.number}`) f.description.includes(`PR #${prNumber}`)
); );
if (newFeature) { if (newFeature) {
@@ -1255,12 +1207,19 @@ export function BoardView() {
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL // If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
if (prUrl && selectedWorktreeForAction?.branch) { if (prUrl && selectedWorktreeForAction?.branch) {
const branchName = selectedWorktreeForAction.branch; const branchName = selectedWorktreeForAction.branch;
hookFeatures const featuresToUpdate = hookFeatures.filter((f) => f.branchName === branchName);
.filter((f) => f.branchName === branchName)
.forEach((feature) => { // Update local state synchronously
featuresToUpdate.forEach((feature) => {
updateFeature(feature.id, { prUrl }); updateFeature(feature.id, { prUrl });
persistFeatureUpdate(feature.id, { prUrl });
}); });
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) =>
persistFeatureUpdate(feature.id, { prUrl })
)
).catch(console.error);
} }
setWorktreeRefreshKey((k) => k + 1); setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null); setSelectedWorktreeForAction(null);

View File

@@ -699,7 +699,10 @@ export const KanbanCard = memo(function KanbanCard({
)} )}
{/* PR URL Display */} {/* PR URL Display */}
{feature.prUrl && ( {typeof feature.prUrl === "string" &&
/^https?:\/\//i.test(feature.prUrl) && (() => {
const prNumber = feature.prUrl.split('/').pop();
return (
<div className="mb-2"> <div className="mb-2">
<a <a
href={feature.prUrl} href={feature.prUrl}
@@ -712,11 +715,14 @@ export const KanbanCard = memo(function KanbanCard({
data-testid={`pr-url-${feature.id}`} data-testid={`pr-url-${feature.id}`}
> >
<GitPullRequest className="w-3 h-3 shrink-0" /> <GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">Pull Request</span> <span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" /> <ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a> </a>
</div> </div>
)} );
})()}
{/* Steps Preview */} {/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && ( {showSteps && feature.steps && feature.steps.length > 0 && (

View File

@@ -29,12 +29,8 @@ interface CreatePRDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null; worktree: WorktreeInfo | null;
<<<<<<< Updated upstream
onCreated: (prUrl?: string) => void;
=======
projectPath: string | null; projectPath: string | null;
onCreated: () => void; onCreated: (prUrl?: string) => void;
>>>>>>> Stashed changes
} }
export function CreatePRDialog({ export function CreatePRDialog({

View File

@@ -1,7 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
<<<<<<< Updated upstream import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
import { RefreshCw, Globe, Loader2, CircleDot } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Tooltip, Tooltip,
@@ -9,12 +8,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
=======
import { RefreshCw, Globe, Loader2, GitPullRequest } from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types"; import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
>>>>>>> Stashed changes
import { BranchSwitchDropdown } from "./branch-switch-dropdown"; import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
@@ -95,7 +89,6 @@ export function WorktreeTab({
onStopDevServer, onStopDevServer,
onOpenDevServerUrl, onOpenDevServerUrl,
}: WorktreeTabProps) { }: WorktreeTabProps) {
<<<<<<< Updated upstream
// Determine border color based on state: // Determine border color based on state:
// - Running features: cyan border (high visibility, indicates active work) // - Running features: cyan border (high visibility, indicates active work)
// - Uncommitted changes: amber border (warning state, needs attention) // - Uncommitted changes: amber border (warning state, needs attention)
@@ -111,7 +104,7 @@ export function WorktreeTab({
}; };
const borderClasses = getBorderClasses(); const borderClasses = getBorderClasses();
=======
let prBadge: JSX.Element | null = null; let prBadge: JSX.Element | null = null;
if (worktree.pr) { if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? "open"; const prState = worktree.pr.state?.toLowerCase() ?? "open";
@@ -197,7 +190,6 @@ export function WorktreeTab({
</button> </button>
); );
} }
>>>>>>> Stashed changes
return ( return (
<div className={cn("flex items-center rounded-md", borderClasses)}> <div className={cn("flex items-center rounded-md", borderClasses)}>
@@ -225,7 +217,6 @@ export function WorktreeTab({
{cardCount} {cardCount}
</span> </span>
)} )}
<<<<<<< Updated upstream
{hasChanges && ( {hasChanges && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -246,9 +237,7 @@ export function WorktreeTab({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
=======
{prBadge} {prBadge}
>>>>>>> Stashed changes
</Button> </Button>
<BranchSwitchDropdown <BranchSwitchDropdown
worktree={worktree} worktree={worktree}
@@ -292,7 +281,6 @@ export function WorktreeTab({
{cardCount} {cardCount}
</span> </span>
)} )}
<<<<<<< Updated upstream
{hasChanges && ( {hasChanges && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -313,9 +301,7 @@ export function WorktreeTab({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
=======
{prBadge} {prBadge}
>>>>>>> Stashed changes
</Button> </Button>
)} )}