Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/desktop/src/main/lib/workspace/tab-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ export async function createTab(
configManager.write(config);
}

// Create terminal process immediately for terminal tabs with commands
if (tab.type === "terminal" && tab.command) {
const tmuxManager = await import("../tmux-manager").then(
(m) => m.default,
);
const workingDir = worktree.path || workspace.repoPath;

// Create terminal with command - it will be executed automatically
await tmuxManager.create({
id: tab.id,
cwd: workingDir,
command: tab.command,
// Use default dimensions, Terminal component will resize when it loads
cols: 80,
rows: 30,
});
}
Comment on lines +103 to +119
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify command sanitization in tmux-manager to prevent injection.

The tab.command string is passed directly to tmuxManager.create() without validation or sanitization in this layer. Based on the related changes in TaskCard.tsx (which constructs commands using incomplete shell escaping), there is a potential command injection vulnerability if:

  1. The command originates from user-controlled data (task titles/descriptions)
  2. tmux-manager executes the command in a shell context without proper escaping

Run the following script to examine how tmux-manager handles the command parameter:

If tmux-manager passes the command to a shell without additional escaping, you must either:

  • Sanitize/validate tab.command here before passing it
  • Pass the command as a structured array of arguments rather than a shell string
  • Ensure the command is properly escaped at the source (TaskCard.tsx - see related comment)

🏁 Script executed:

#!/bin/bash
# Description: Verify tmux-manager's command handling for injection risks

# Check tmux-manager implementation
ast-grep --pattern $'create($$$) {
  $$$
}'

# Also search for how command is used
rg -nP --type=ts -C5 'command.*exec|spawn.*command' apps/desktop/src/main/lib/tmux-manager

Length of output: 78



🏁 Script executed:

#!/bin/bash
# Find tmux-manager files
fd -e ts -e js "tmux-manager" apps/desktop/src/main/lib/

# Also check parent directory structure
find apps/desktop/src/main/lib -maxdepth 2 -type f -name "*tmux*" 2>/dev/null

# Search for create function calls and command usage
rg -nP --type=ts "create\s*\(" apps/desktop/src/main/lib/ -A3 | head -50

Length of output: 2356



🏁 Script executed:

#!/bin/bash
# View tmux-manager.ts create() method implementation
sed -n '197,250p' apps/desktop/src/main/lib/tmux-manager.ts

# Also search for command execution patterns (spawn, exec, shell)
rg -nP --type=ts "spawn|exec|shell" apps/desktop/src/main/lib/tmux-manager.ts -B2 -A2

Length of output: 3782



🏁 Script executed:

#!/bin/bash
# View executeCommand method implementation
sed -n '476,490p' apps/desktop/src/main/lib/tmux-manager.ts

# Also look for sendKeys or write operations
rg -nP --type=ts "sendKeys|write.*command|pty\.write" apps/desktop/src/main/lib/tmux-manager.ts -B2 -A2

Length of output: 670



🏁 Script executed:

#!/bin/bash
# View the write() method implementation
rg -nP --type=ts "^\s*write\s*\(" apps/desktop/src/main/lib/tmux-manager.ts -A15

Length of output: 510



🏁 Script executed:

#!/bin/bash
# View tab-operations.ts to see where tab.command comes from
sed -n '1,50p' apps/desktop/src/main/lib/workspace/tab-operations.ts

# Search for tab.command assignment
rg -nP --type=ts "tab\.command\s*=" apps/desktop/src/main/lib/workspace/tab-operations.ts -B3 -A1

# Check input source
sed -n '40,50p' apps/desktop/src/main/lib/workspace/tab-operations.ts

Length of output: 1963



🏁 Script executed:

#!/bin/bash
# Find TaskCard files
fd -e tsx -e ts "TaskCard" apps/desktop/src

# Search for command construction in TaskCard
rg -nP "command.*=" apps/desktop/src --type=tsx -B2 -A2 | grep -i "task\|card" -A5 -B5 | head -80

Length of output: 99



🏁 Script executed:

#!/bin/bash
# View TaskCard.tsx - search for command construction
rg -n "command" apps/desktop/src/renderer/screens/main/components/PlanView/TaskCard.tsx -B3 -A3

# Also check for shell escaping patterns
rg -n "escape|quote|shell" apps/desktop/src/renderer/screens/main/components/PlanView/TaskCard.tsx -B2 -A2

Length of output: 1143


Verify command sanitization in tmux-manager to prevent injection — VULNERABILITY CONFIRMED.

The tab.command string is passed directly to tmuxManager.create() without validation or sanitization. The underlying vulnerability stems from incomplete shell escaping in TaskCard.tsx (lines 84-87), which only escapes backslashes, double quotes, and newlines but omits critical shell metacharacters like $, backticks (`), !, and command substitution syntax.

Execution path:

  • TaskCard.tsx constructs command with incomplete escaping
  • Command flows through input.command → tab.command → tmuxManager.create() → PTY.write()
  • tmux-manager writes directly to bash PTY (line 462) without additional escaping

Risks:

  1. User input containing $(command) or `command` bypasses current escaping and executes arbitrary code
  2. No secondary validation layer exists between command construction and shell execution

Required fix: Either properly escape all shell metacharacters using a robust method (e.g., shell escaping library or printf %q equivalent) or refactor to pass commands as argument arrays rather than shell strings.

🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/workspace/tab-operations.ts around lines 103 to
119, tab.command is forwarded directly into tmuxManager.create() which passes it
to a shell/PTY without sufficient escaping, allowing shell metacharacter
injection; fix by validating and sanitizing the command before passing it:
either change the call site and tmux-manager API to accept command as an
argument array (preferred) and pass args directly to the PTY/shell without
joining into a shell string, or, if a string must be used, run the command
through a proven shell-escaping library (or implement printf %q-style quoting)
that escapes all metacharacters ($, `, !, ;, |, &, >, <, (), {}, *, ?, [, ],
etc.), and add a defensive runtime check that rejects or escapes any dangerous
constructs before invoking tmuxManager.create().


return { success: true, tab };
} catch (error) {
console.error("Failed to create tab:", error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,31 +177,8 @@ function TerminalTabContent({
onFocus,
}: TerminalTabContentProps) {
const terminalId = tab.id;
const terminalCreatedRef = useRef(false);
const isSelected = selectedTabId === tab.id;

// Terminal creation and lifecycle
// NOTE: Actual terminal-create is now deferred to the Terminal component
// so it can pass the correct dimensions when ready
useEffect(() => {
// Execute startup command if specified (only after terminal is created)
if (
tab.command &&
tab.command.trim() !== "" &&
!terminalCreatedRef.current
) {
terminalCreatedRef.current = true;
const commandToExecute = tab.command;
// Wait for terminal to be created and attached
setTimeout(() => {
window.ipcRenderer.invoke("terminal-execute-command", {
id: tab.id,
command: commandToExecute,
});
}, 1000);
}
}, [tab.id, tab.command]);

// Listen for CWD changes from the main process
useEffect(() => {
if (!terminalId || !workspaceId || !worktreeId) return;
Expand Down Expand Up @@ -241,7 +218,6 @@ function TerminalTabContent({
hidden={!isSelected}
onFocus={onFocus}
cwd={terminalCwd}
command={tab.command}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ interface TerminalProps {
hidden?: boolean;
onFocus?: () => void;
cwd?: string;
command?: string | null;
}

interface TerminalMessage {
Expand Down Expand Up @@ -76,7 +75,6 @@ export default function TerminalComponent({
hidden = false,
onFocus,
cwd,
command,
}: TerminalProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const [terminal, setTerminal] = useState<XTerm | null>(null);
Expand Down Expand Up @@ -301,7 +299,6 @@ export default function TerminalComponent({
cwd,
cols,
rows,
command: command || undefined,
})
.catch((error: Error) => {
console.error("Failed to create terminal:", error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,40 @@ export const NewLayoutMain: React.FC = () => {
}
};

// Optimistically add a tab to the current workspace
const handleTabCreated = (worktreeId: string, tab: Tab) => {
if (!currentWorkspace) return;

// Find the worktree and add the tab
const updatedWorktrees = currentWorkspace.worktrees.map((wt) => {
if (wt.id === worktreeId) {
return {
...wt,
tabs: [...wt.tabs, tab],
};
}
return wt;
});

const updatedWorkspace = {
...currentWorkspace,
worktrees: updatedWorktrees,
activeWorktreeId: worktreeId,
activeTabId: tab.id,
};

setCurrentWorkspace(updatedWorkspace);

// Also update in workspaces array
if (workspaces) {
setWorkspaces(
workspaces.map((ws) =>
ws.id === currentWorkspace.id ? updatedWorkspace : ws,
),
);
}
};

// Handle tab selection
const handleTabSelect = (worktreeId: string, tabId: string) => {
setSelectedWorktreeId(worktreeId);
Expand Down Expand Up @@ -949,7 +983,7 @@ export const NewLayoutMain: React.FC = () => {
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={handleTabSelect}
onReload={loadAllWorkspaces}
onTabCreated={handleTabCreated}
/>
) : (
// Edit mode - show workspace/terminal view
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RouterOutputs } from "@superset/api";
import type React from "react";
import type { Workspace } from "shared/types";
import type { Tab, Workspace } from "shared/types";
import { TaskCard } from "./TaskCard";

type Task = RouterOutputs["task"]["all"][number];
Expand All @@ -13,7 +13,7 @@ interface KanbanColumnProps {
currentWorkspace: Workspace | null;
selectedWorktreeId: string | null;
onTabSelect: (worktreeId: string, tabId: string) => void;
onReload: () => void;
onTabCreated: (worktreeId: string, tab: Tab) => void;
onUpdateTask: (
taskId: string,
updates: {
Expand All @@ -33,7 +33,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
currentWorkspace,
selectedWorktreeId,
onTabSelect,
onReload,
onTabCreated,
onUpdateTask,
}) => {
return (
Expand Down Expand Up @@ -61,7 +61,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
onUpdateTask={onUpdateTask}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { RouterOutputs } from "@superset/api";
import { Plus } from "lucide-react";
import type React from "react";
import { useMemo, useState } from "react";
import type { Workspace } from "shared/types";
import type { Tab, Workspace } from "shared/types";
import { mockTasks, mockUsers } from "../../../../../lib/mock-data";
import { CreateTaskModal } from "./CreateTaskModal";
import { KanbanColumn } from "./KanbanColumn";
Expand All @@ -15,14 +15,14 @@ interface PlanViewProps {
currentWorkspace: Workspace | null;
selectedWorktreeId: string | null;
onTabSelect: (worktreeId: string, tabId: string) => void;
onReload: () => void;
onTabCreated: (worktreeId: string, tab: Tab) => void;
}

export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace,
selectedWorktreeId,
onTabSelect,
onReload,
onTabCreated,
}) => {
// Initialize with mock tasks and add some variety to statuses
const [tasks, setTasks] = useState<Task[]>(() => {
Expand Down Expand Up @@ -144,7 +144,7 @@ export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
/>
);
}
Expand Down Expand Up @@ -183,7 +183,7 @@ export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
onUpdateTask={handleUpdateTask}
/>
<KanbanColumn
Expand All @@ -194,7 +194,7 @@ export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
onUpdateTask={handleUpdateTask}
/>
<KanbanColumn
Expand All @@ -205,7 +205,7 @@ export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
onUpdateTask={handleUpdateTask}
/>
<KanbanColumn
Expand All @@ -216,7 +216,7 @@ export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
onUpdateTask={handleUpdateTask}
/>
<KanbanColumn
Expand All @@ -227,7 +227,7 @@ export const PlanView: React.FC<PlanViewProps> = ({
currentWorkspace={currentWorkspace}
selectedWorktreeId={selectedWorktreeId}
onTabSelect={onTabSelect}
onReload={onReload}
onTabCreated={onTabCreated}
onUpdateTask={handleUpdateTask}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { RouterOutputs } from "@superset/api";
import { Play } from "lucide-react";
import type React from "react";
import { useState } from "react";
import type { Workspace } from "shared/types";
import type { Tab, Workspace } from "shared/types";

type Task = RouterOutputs["task"]["all"][number];

Expand All @@ -12,7 +12,7 @@ interface TaskCardProps {
currentWorkspace: Workspace | null;
selectedWorktreeId: string | null;
onTabSelect: (worktreeId: string, tabId: string) => void;
onReload: () => void;
onTabCreated: (worktreeId: string, tab: Tab) => void;
onUpdateTask: (
taskId: string,
updates: {
Expand Down Expand Up @@ -41,7 +41,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
currentWorkspace,
selectedWorktreeId,
onTabSelect,
onReload,
onTabCreated,
onUpdateTask,
}) => {
const statusColor = statusColors[task.status] || "bg-neutral-500";
Expand Down Expand Up @@ -79,33 +79,35 @@ export const TaskCard: React.FC<TaskCardProps> = ({

try {
// Create a new terminal with claude command
const taskPrompt = `${task.title}\n\n${task.description || ""}`.trim();
// Escape quotes and newlines for shell command
const escapedPrompt = taskPrompt
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\n/g, '\\n'); // Escape newlines
const result = await window.ipcRenderer.invoke("tab-create", {
workspaceId: currentWorkspace.id,
worktreeId: targetWorktreeId,
name: `Task: ${task.slug}`,
type: "terminal",
command: `claude "hi"`,
command: `claude "${escapedPrompt}"`,
Comment on lines +82 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Incomplete shell escaping enables command injection.

The current escaping logic only handles backslashes, double quotes, and newlines. It's missing critical shell metacharacters that are interpreted within double quotes:

  • $ → variable expansion: ${task.title} containing $HOME would expand
  • ` → command substitution: backticks allow arbitrary command execution
  • ! → history expansion (in some shells with histexpand enabled)

If a task title or description contains these characters, they will be executed in the shell context, allowing arbitrary code execution.

Recommended fix: Use a safer approach by passing the prompt as a separate argument or using proper shell escaping:

-		try {
-			// Create a new terminal with claude command
-			const taskPrompt = `${task.title}\n\n${task.description || ""}`.trim();
-			// Escape quotes and newlines for shell command
-			const escapedPrompt = taskPrompt
-				.replace(/\\/g, '\\\\')  // Escape backslashes first
-				.replace(/"/g, '\\"')     // Escape double quotes
-				.replace(/\n/g, '\\n');   // Escape newlines
+		try {
+			// Create a new terminal with claude command
+			const taskPrompt = `${task.title}\n\n${task.description || ""}`.trim();
+			// Escape for shell: backslashes, quotes, dollar signs, backticks, and newlines
+			const escapedPrompt = taskPrompt
+				.replace(/\\/g, '\\\\')   // Escape backslashes first
+				.replace(/"/g, '\\"')      // Escape double quotes
+				.replace(/\$/g, '\\$')     // Escape dollar signs
+				.replace(/`/g, '\\`')      // Escape backticks
+				.replace(/!/g, '\\!')      // Escape exclamation marks
+				.replace(/\n/g, '\\n');    // Escape newlines
 			const result = await window.ipcRenderer.invoke("tab-create", {

Better alternative: If the IPC handler supports it, consider passing the prompt as a separate parameter rather than embedding it in the command string, avoiding shell escaping entirely.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const taskPrompt = `${task.title}\n\n${task.description || ""}`.trim();
// Escape quotes and newlines for shell command
const escapedPrompt = taskPrompt
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\n/g, '\\n'); // Escape newlines
const result = await window.ipcRenderer.invoke("tab-create", {
workspaceId: currentWorkspace.id,
worktreeId: targetWorktreeId,
name: `Task: ${task.slug}`,
type: "terminal",
command: `claude "hi"`,
command: `claude "${escapedPrompt}"`,
const taskPrompt = `${task.title}\n\n${task.description || ""}`.trim();
// Escape for shell: backslashes, quotes, dollar signs, backticks, and newlines
const escapedPrompt = taskPrompt
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\$/g, '\\$') // Escape dollar signs
.replace(/`/g, '\\`') // Escape backticks
.replace(/!/g, '\\!') // Escape exclamation marks
.replace(/\n/g, '\\n'); // Escape newlines
const result = await window.ipcRenderer.invoke("tab-create", {
workspaceId: currentWorkspace.id,
worktreeId: targetWorktreeId,
name: `Task: ${task.slug}`,
type: "terminal",
command: `claude "${escapedPrompt}"`,
🤖 Prompt for AI Agents
In apps/desktop/src/renderer/screens/main/components/PlanView/TaskCard.tsx
around lines 82 to 93, the current escaping only handles backslashes, double
quotes and newlines leaving shell metacharacters like $, `, and ! unescaped
which allows command injection; fix by not embedding the prompt in a single
shell command string—change the ipcRenderer.invoke payload to send the prompt as
a separate field (e.g. prompt: taskPrompt) or send a command + args array so the
main process can spawn/execFile safely with the prompt as an argument, or if you
cannot change the IPC contract, implement robust shell-escaping on the renderer
side (escape $, `, ! and properly handle single quotes using the standard
close-quote-escape-quote pattern) and update the main process to use
spawn/execFile with args rather than passing a full shell command to a shell.

});

if (result.success) {
if (result.success && result.tab) {
const newTabId = result.tab.id;

// Update task status to planning (pending)
onUpdateTask(task.id, {
title: task.title,
description: task.description || "",
status: "planning",
});

// Reload workspace to get updated tab data
await onReload();

// Select the new tab after reload
const newTabId = result.tab?.id;
if (newTabId) {
// Small delay to ensure workspace is reloaded
setTimeout(() => {
onTabSelect(targetWorktreeId, newTabId);
}, 100);
}
// Optimistically add the tab to state
onTabCreated(targetWorktreeId, result.tab);

// Select the new tab immediately
onTabSelect(targetWorktreeId, newTabId);
}
} catch (error) {
console.error("Error starting task:", error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const statusLabels: Record<string, string> = {
backlog: "Backlog",
todo: "Todo",
planning: "Pending",
planning: "In Progress",
working: "Working",
"needs-feedback": "Needs Feedback",
"ready-to-merge": "Ready to Merge",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { RouterOutputs } from "@superset/api";
import { ChevronDown, ChevronLeft, Play, User as UserIcon } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import type { Workspace } from "shared/types";
import type { Tab, Workspace } from "shared/types";

type Task = RouterOutputs["task"]["all"][number];
type User = RouterOutputs["user"]["all"][number];
Expand All @@ -23,7 +23,7 @@ interface TaskPageProps {
currentWorkspace: Workspace | null;
selectedWorktreeId: string | null;
onTabSelect: (worktreeId: string, tabId: string) => void;
onReload: () => void;
onTabCreated: (worktreeId: string, tab: Tab) => void;
}

const statusColors: Record<string, string> = {
Expand Down Expand Up @@ -56,7 +56,7 @@ export const TaskPage: React.FC<TaskPageProps> = ({
currentWorkspace,
selectedWorktreeId,
onTabSelect,
onReload,
onTabCreated,
}) => {
const statusColor = statusColors[task.status] || "bg-neutral-500";
const [title, setTitle] = useState(task.title);
Expand Down Expand Up @@ -175,7 +175,7 @@ export const TaskPage: React.FC<TaskPageProps> = ({
});

// Reload workspace to get updated tab data
await onReload();
await onTabCreated();

// Select the new tab after reload
const newTabId = result.tab?.id;
Expand Down
Loading