mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user