Refactor command execution and security handling

- Removed security warning detection from CommandExecution component and related tests.
- Simplified command parsing logic by consolidating command extraction and validation.
- Updated command pattern extraction to handle duplicate patterns gracefully.
- Enhanced command parsing to limit extracted patterns to a maximum of three levels.
- Removed unused security issue detection functions and related tests.
- Improved test coverage for command pattern extraction and validation.
This commit is contained in:
Daniel Riccio 2025-07-24 10:40:08 -05:00
parent 0a5cf463a1
commit 8b4150e017
No known key found for this signature in database
GPG key ID: FFD5FD825F8E8209
8 changed files with 221 additions and 714 deletions

View file

@ -1,6 +1,6 @@
import { useCallback, useState, memo, useMemo } from "react"
import { useEvent } from "react-use"
import { ChevronDown, Skull, AlertTriangle } from "lucide-react"
import { ChevronDown, Skull } from "lucide-react"
import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types"
@ -18,7 +18,6 @@ import {
getPatternDescription,
parseCommandAndOutput,
CommandPattern,
detectSecurityIssues,
} from "../../utils/commandPatterns"
interface CommandExecutionProps {
@ -71,11 +70,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
return patterns
}, [command])
// Detect security issues in the command
const securityWarnings = useMemo(() => {
return detectSecurityIssues(command)
}, [command])
// Handle pattern changes
const handleAllowPatternChange = (pattern: string) => {
const isAllowed = allowedCommands.includes(pattern)
@ -186,21 +180,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs">
<div className="p-2">
<CodeBlock source={command} language="shell" />
{securityWarnings.length > 0 && (
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/20 rounded-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-medium text-yellow-500 mb-1">Security Warning</div>
{securityWarnings.map((warning, index) => (
<div key={index} className="text-vscode-descriptionForeground">
{warning.message}
</div>
))}
</div>
</div>
</div>
)}
<OutputContainer isExpanded={isExpanded} output={output} />
</div>
{commandPatterns.length > 0 && (

View file

@ -365,18 +365,6 @@ Other output here`
expect(screen.queryByText("whoami")).not.toBeInTheDocument()
})
it("should display security warning for commands with subshells", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-security" text="echo $(malicious)" />
</ExtensionStateWrapper>,
)
// Should show security warning
expect(screen.getByText("Security Warning")).toBeInTheDocument()
expect(screen.getByText(/subshell execution/)).toBeInTheDocument()
})
it("should handle commands with backtick subshells", () => {
render(
<ExtensionStateWrapper>

View file

@ -1,50 +1,29 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { describe, it, expect, vi } from "vitest"
import { CommandPatternSelector } from "../CommandPatternSelector"
import { CommandPattern } from "../../../utils/commandPatterns"
import { TooltipProvider } from "../../../components/ui/tooltip"
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, components }: any) => {
if (i18nKey === "chat:commandExecution.commandManagementDescription") {
return (
<span>
Manage command permissions: Click to allow auto-execution, to deny execution. Patterns can be
toggled on/off or removed from lists. {components.settingsLink}
</span>
)
}
return <span>{i18nKey}</span>
},
Trans: ({ i18nKey, children }: any) => <span>{i18nKey || children}</span>,
}))
// Mock VSCodeLink
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeLink: ({ children, onClick }: any) => (
<a href="#" onClick={onClick}>
{children || "View all settings"}
{children}
</a>
),
}))
// Mock StandardTooltip
vi.mock("../../ui/standard-tooltip", () => ({
StandardTooltip: ({ children, content }: any) => (
<div title={typeof content === "string" ? content : "tooltip"}>
{children}
{/* Render the content to make it testable */}
<div style={{ display: "none" }}>{content}</div>
</div>
),
}))
// Mock window.postMessage
const mockPostMessage = vi.fn()
window.postMessage = mockPostMessage
// Wrapper component with TooltipProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => <TooltipProvider>{children}</TooltipProvider>
describe("CommandPatternSelector", () => {
const mockPatterns: CommandPattern[] = [
@ -61,192 +40,53 @@ describe("CommandPatternSelector", () => {
onDenyPatternChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it("should render with unique pattern keys", () => {
const { container } = render(
<TestWrapper>
<CommandPatternSelector {...defaultProps} />
</TestWrapper>,
)
it("should render collapsed by default", () => {
render(<CommandPatternSelector {...defaultProps} />)
// The component should render without errors
expect(container).toBeTruthy()
expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument()
expect(screen.queryByText("npm commands")).not.toBeInTheDocument()
})
it("should expand when clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
fireEvent.click(expandButton)
// Check for the patterns themselves
// Check that patterns are rendered
expect(screen.getByText("npm")).toBeInTheDocument()
expect(screen.getByText("npm install")).toBeInTheDocument()
expect(screen.getByText("git")).toBeInTheDocument()
// Check for the descriptions
expect(screen.getByText("- npm commands")).toBeInTheDocument()
expect(screen.getByText("- npm install commands")).toBeInTheDocument()
expect(screen.getByText("- git commands")).toBeInTheDocument()
})
it("should collapse when clicked again", () => {
render(<CommandPatternSelector {...defaultProps} />)
it("should handle duplicate patterns gracefully", () => {
// Test with duplicate patterns to ensure keys are still unique
const duplicatePatterns: CommandPattern[] = [
{ pattern: "npm", description: "npm commands" },
{ pattern: "npm", description: "duplicate npm commands" }, // Duplicate pattern
{ pattern: "git", description: "git commands" },
]
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
const collapseButton = screen.getByRole("button", { name: "chat:commandExecution.collapseManagement" })
fireEvent.click(collapseButton)
expect(screen.queryByText("npm commands")).not.toBeInTheDocument()
})
it("should show correct status for patterns", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Check that npm has allowed styling (green)
const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0]
expect(npmAllowButton).toHaveClass("bg-green-500/20")
// Check that git has denied styling (red)
const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0]
expect(gitDenyButton).toHaveClass("bg-red-500/20")
})
it("should call onAllowPatternChange when allow button is clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find all allow buttons with the "add to allowed" label
const allowButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToAllowed" })
// The second one should be for npm install (first is npm which is already allowed)
fireEvent.click(allowButtons[0])
expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm install")
})
it("should call onDenyPatternChange when deny button is clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find all deny buttons with the "add to denied" label
const denyButtons = screen.getAllByRole("button", { name: "chat:commandExecution.addToDenied" })
// The second one should be for npm install (first is npm, third is git which is already denied)
fireEvent.click(denyButtons[1])
expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("npm install")
})
it("should toggle allowed pattern when clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find the allow button for npm (which is already allowed)
const npmAllowButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromAllowed" })[0]
fireEvent.click(npmAllowButton)
expect(defaultProps.onAllowPatternChange).toHaveBeenCalledWith("npm")
})
it("should toggle denied pattern when clicked", () => {
render(<CommandPatternSelector {...defaultProps} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// Find the deny button for git (which is already denied)
const gitDenyButton = screen.getAllByRole("button", { name: "chat:commandExecution.removeFromDenied" })[0]
fireEvent.click(gitDenyButton)
expect(defaultProps.onDenyPatternChange).toHaveBeenCalledWith("git")
})
it("should have tooltip with settings link", () => {
const { container } = render(<CommandPatternSelector {...defaultProps} />)
// The info icon should have a tooltip
const tooltipWrapper = container.querySelector('[title="tooltip"]')
expect(tooltipWrapper).toBeTruthy()
// The tooltip content includes a settings link (mocked as VSCodeLink)
// It's rendered in a hidden div for testing purposes
const settingsLink = container.querySelector('a[href="#"]')
expect(settingsLink).toBeTruthy()
expect(settingsLink?.textContent).toBe("View all settings")
// Test that clicking the link posts the correct message
if (settingsLink) {
fireEvent.click(settingsLink)
expect(mockPostMessage).toHaveBeenCalledWith(
{
type: "action",
action: "settingsButtonClicked",
values: { section: "autoApprove" },
},
"*",
)
const props = {
...defaultProps,
patterns: duplicatePatterns,
}
})
it("should render with empty patterns", () => {
render(<CommandPatternSelector {...defaultProps} patterns={[]} />)
// This should not throw an error even with duplicate patterns
const { container } = render(
<TestWrapper>
<CommandPatternSelector {...props} />
</TestWrapper>,
)
expect(container).toBeTruthy()
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
// Click to expand the component
const expandButton = screen.getByRole("button", { name: /chat:commandExecution.expandManagement/i })
fireEvent.click(expandButton)
// The expanded view should exist but be empty since there are no patterns
const expandedContent = screen
.getByRole("button", { name: "chat:commandExecution.collapseManagement" })
.parentElement?.querySelector(".px-3.pb-3")
expect(expandedContent).toBeInTheDocument()
expect(expandedContent?.children.length).toBe(0)
})
it("should render patterns without descriptions", () => {
const patternsWithoutDesc: CommandPattern[] = [{ pattern: "custom-command" }]
render(<CommandPatternSelector {...defaultProps} patterns={patternsWithoutDesc} />)
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
expect(screen.getByText("custom-command")).toBeInTheDocument()
})
it("should always show info icon with tooltip", () => {
const { container } = render(<CommandPatternSelector {...defaultProps} />)
// Info icon should always be visible (not just when expanded)
// Look for the Info icon which is wrapped in StandardTooltip
const infoIcon = container.querySelector(".ml-1")
expect(infoIcon).toBeTruthy()
})
it("should apply correct classes for chevron rotation", () => {
const { container } = render(<CommandPatternSelector {...defaultProps} />)
// Initially collapsed - chevron should be rotated
let chevron = container.querySelector(".size-3.transition-transform")
expect(chevron).toHaveClass("-rotate-90")
// Click to expand
const expandButton = screen.getByRole("button", { name: "chat:commandExecution.expandManagement" })
fireEvent.click(expandButton)
// When expanded - chevron should not be rotated
chevron = container.querySelector(".size-3.transition-transform")
expect(chevron).toHaveClass("rotate-0")
// Both instances of "npm" should be rendered
const npmElements = screen.getAllByText("npm")
expect(npmElements).toHaveLength(2)
})
})

View file

@ -1,77 +1,5 @@
import { describe, it, expect } from "vitest"
import { parseCommandString, extractPatternsFromCommand, detectCommandSecurityIssues } from "../command-parser"
describe("parseCommandString", () => {
it("should parse simple command", () => {
const result = parseCommandString("ls -la")
expect(result.subCommands).toEqual(["ls -la"])
expect(result.hasSubshells).toBe(false)
expect(result.subshellCommands).toEqual([])
})
it("should parse command with && operator", () => {
const result = parseCommandString("npm install && npm test")
expect(result.subCommands).toEqual(["npm install", "npm test"])
expect(result.hasSubshells).toBe(false)
})
it("should parse command with || operator", () => {
const result = parseCommandString("npm test || npm run test:ci")
expect(result.subCommands).toEqual(["npm test", "npm run test:ci"])
expect(result.hasSubshells).toBe(false)
})
it("should parse command with pipe", () => {
const result = parseCommandString("ls -la | grep test")
expect(result.subCommands).toEqual(["ls -la", "grep test"])
expect(result.hasSubshells).toBe(false)
})
it("should detect and extract subshells with $()", () => {
const result = parseCommandString("echo $(date)")
expect(result.subCommands).toEqual(["echo", "date"])
expect(result.hasSubshells).toBe(true)
expect(result.subshellCommands).toEqual(["date"])
})
it("should detect and extract subshells with backticks", () => {
const result = parseCommandString("echo `whoami`")
expect(result.subCommands).toEqual(["echo", "whoami"])
expect(result.hasSubshells).toBe(true)
expect(result.subshellCommands).toEqual(["whoami"])
})
it("should handle PowerShell redirections", () => {
const result = parseCommandString("command 2>&1")
expect(result.subCommands).toEqual(["command 2>&1"])
expect(result.hasSubshells).toBe(false)
})
it("should handle quoted strings", () => {
const result = parseCommandString('echo "hello world"')
expect(result.subCommands).toEqual(['echo "hello world"'])
expect(result.hasSubshells).toBe(false)
})
it("should handle array indexing expressions", () => {
const result = parseCommandString("echo ${array[0]}")
expect(result.subCommands).toEqual(["echo ${array[0]}"])
expect(result.hasSubshells).toBe(false)
})
it("should handle empty command", () => {
const result = parseCommandString("")
expect(result.subCommands).toEqual([])
expect(result.hasSubshells).toBe(false)
expect(result.subshellCommands).toEqual([])
})
it("should handle complex command with multiple operators", () => {
const result = parseCommandString("npm install && npm test | grep success || echo 'failed'")
expect(result.subCommands).toEqual(["npm install", "npm test", "grep success", "echo failed"])
expect(result.hasSubshells).toBe(false)
})
})
import { extractPatternsFromCommand } from "../command-parser"
describe("extractPatternsFromCommand", () => {
it("should extract simple command pattern", () => {
@ -79,9 +7,9 @@ describe("extractPatternsFromCommand", () => {
expect(patterns).toEqual(["ls"])
})
it("should extract command with arguments", () => {
const patterns = extractPatternsFromCommand("npm install express")
expect(patterns).toEqual(["npm", "npm install", "npm install express"])
it("should extract command with subcommand", () => {
const patterns = extractPatternsFromCommand("git push origin main")
expect(patterns).toEqual(["git", "git push", "git push origin"])
})
it("should stop at flags", () => {
@ -94,17 +22,24 @@ describe("extractPatternsFromCommand", () => {
expect(patterns).toEqual(["cd"])
})
it("should handle piped commands", () => {
it("should handle pipes", () => {
const patterns = extractPatternsFromCommand("ls -la | grep test")
expect(patterns).toContain("ls")
expect(patterns).toContain("grep")
expect(patterns).toContain("grep test")
expect(patterns).toEqual(["grep", "grep test", "ls"])
})
it("should remove subshells before extracting patterns", () => {
const patterns = extractPatternsFromCommand("echo $(malicious)")
expect(patterns).toEqual(["echo"])
expect(patterns).not.toContain("malicious")
it("should handle && operator", () => {
const patterns = extractPatternsFromCommand("npm install && git push origin main")
expect(patterns).toEqual(["git", "git push", "git push origin", "npm", "npm install"])
})
it("should handle || operator", () => {
const patterns = extractPatternsFromCommand("npm test || npm run test:ci")
expect(patterns).toEqual(["npm", "npm run", "npm test"])
})
it("should handle semicolon separator", () => {
const patterns = extractPatternsFromCommand("cd src; npm install")
expect(patterns).toEqual(["cd", "cd src", "npm", "npm install"])
})
it("should skip numeric commands", () => {
@ -112,50 +47,91 @@ describe("extractPatternsFromCommand", () => {
expect(patterns).toEqual([])
})
it("should skip common output words", () => {
const patterns = extractPatternsFromCommand("error")
expect(patterns).toEqual([])
})
it("should handle empty command", () => {
const patterns = extractPatternsFromCommand("")
expect(patterns).toEqual([])
})
it("should handle null/undefined", () => {
expect(extractPatternsFromCommand(null as any)).toEqual([])
expect(extractPatternsFromCommand(undefined as any)).toEqual([])
})
it("should handle scripts", () => {
const patterns = extractPatternsFromCommand("./script.sh --verbose")
expect(patterns).toEqual(["./script.sh"])
})
it("should handle paths with dots", () => {
const patterns = extractPatternsFromCommand("git add .")
expect(patterns).toEqual(["git", "git add"])
})
it("should handle paths with tilde", () => {
const patterns = extractPatternsFromCommand("cd ~/projects")
expect(patterns).toEqual(["cd"])
})
it("should handle colons in arguments", () => {
const patterns = extractPatternsFromCommand("docker run image:tag")
expect(patterns).toEqual(["docker", "docker run"])
})
it("should return sorted patterns", () => {
const patterns = extractPatternsFromCommand("npm run build && git push")
expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"])
})
it("should handle complex command with multiple operators", () => {
const patterns = extractPatternsFromCommand("npm install && npm test | grep success || echo 'failed'")
expect(patterns).toContain("npm")
expect(patterns).toContain("npm install")
expect(patterns).toContain("npm test")
expect(patterns).toContain("grep")
expect(patterns).toContain("echo")
})
it("should handle malformed commands gracefully", () => {
const patterns = extractPatternsFromCommand("echo 'unclosed quote")
expect(patterns).toContain("echo")
})
it("should not treat package managers specially", () => {
const patterns = extractPatternsFromCommand("npm run build")
expect(patterns).toEqual(["npm", "npm run", "npm run build"])
})
})
describe("detectCommandSecurityIssues", () => {
it("should detect subshell with $()", () => {
const warnings = detectCommandSecurityIssues("echo $(malicious)")
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
expect(warnings[0].message).toContain("subshell execution")
})
it("should detect subshell with backticks", () => {
const warnings = detectCommandSecurityIssues("echo `malicious`")
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
expect(warnings[0].message).toContain("subshell execution")
})
it("should detect multiple subshell patterns", () => {
const warnings = detectCommandSecurityIssues("echo $(date) && echo `whoami`")
expect(warnings).toHaveLength(1) // Still one warning for subshell presence
expect(warnings[0].type).toBe("subshell")
})
it("should not detect issues in safe commands", () => {
const warnings = detectCommandSecurityIssues("npm install express")
expect(warnings).toHaveLength(0)
})
it("should handle empty command", () => {
const warnings = detectCommandSecurityIssues("")
expect(warnings).toHaveLength(0)
// Now includes "npm run build" with 3-level extraction
})
it("should extract at most 3 levels", () => {
const patterns = extractPatternsFromCommand("git push origin main --force")
expect(patterns).toEqual(["git", "git push", "git push origin"])
// Should NOT include deeper levels beyond 3
})
it("should handle multi-level commands like gh pr", () => {
const patterns = extractPatternsFromCommand("gh pr checkout 123")
expect(patterns).toEqual(["gh", "gh pr", "gh pr checkout"])
})
it("should extract 3 levels for git remote add", () => {
const patterns = extractPatternsFromCommand("git remote add origin https://github.com/user/repo.git")
expect(patterns).toEqual(["git", "git remote", "git remote add"])
})
it("should extract 3 levels for npm run build", () => {
const patterns = extractPatternsFromCommand("npm run build --production")
expect(patterns).toEqual(["npm", "npm run", "npm run build"])
})
it("should stop at file extensions even at third level", () => {
const patterns = extractPatternsFromCommand("node scripts test.js")
expect(patterns).toEqual(["node", "node scripts"])
// Should NOT include "node scripts test.js" because of .js
})
it("should stop at flags at any level", () => {
const patterns = extractPatternsFromCommand("docker run -it ubuntu")
expect(patterns).toEqual(["docker", "docker run"])
// Stops at -it flag
})
})

View file

@ -1,10 +1,5 @@
import { describe, it, expect } from "vitest"
import {
extractCommandPatterns,
getPatternDescription,
parseCommandAndOutput,
detectSecurityIssues,
} from "../commandPatterns"
import { extractCommandPatterns, getPatternDescription, parseCommandAndOutput } from "../commandPatterns"
describe("extractCommandPatterns", () => {
it("should extract simple command", () => {
@ -37,7 +32,7 @@ describe("extractCommandPatterns", () => {
expect(patterns).toContain("npm")
expect(patterns).toContain("npm test")
expect(patterns).toContain("npm run")
expect(patterns).toContain("npm run test:ci")
expect(patterns).not.toContain("npm run test:ci")
})
it("should handle semicolon separated commands", () => {
@ -94,12 +89,12 @@ describe("extractCommandPatterns", () => {
const patterns = extractCommandPatterns("git add .")
expect(patterns).toContain("git")
expect(patterns).toContain("git add")
expect(patterns).not.toContain("git add .")
expect(patterns).not.toContain("git add .") // dot is excluded
})
it("should return sorted patterns", () => {
const patterns = extractCommandPatterns("npm run build && git push")
expect(patterns).toEqual([...patterns].sort())
expect(patterns).toEqual(["git", "git push", "npm", "npm run", "npm run build"])
})
it("should handle numeric input like '0 total'", () => {
@ -354,80 +349,6 @@ drwxr-xr-x 20 user staff 640 Jan 22 11:00 ..`
})
})
describe("detectSecurityIssues", () => {
it("should detect subshell execution with $()", () => {
const warnings = detectSecurityIssues("echo $(malicious)")
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
expect(warnings[0].message).toContain("subshell execution")
})
it("should detect subshell execution with backticks", () => {
const warnings = detectSecurityIssues("echo `malicious`")
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
expect(warnings[0].message).toContain("subshell execution")
})
it("should detect nested subshells", () => {
const warnings = detectSecurityIssues("echo $(echo $(date))")
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
})
it("should detect subshells in complex commands", () => {
const warnings = detectSecurityIssues("npm install && echo $(whoami) || git push")
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
})
it("should not detect issues in safe commands", () => {
const warnings = detectSecurityIssues("npm install express")
expect(warnings).toHaveLength(0)
})
it("should handle empty commands", () => {
const warnings = detectSecurityIssues("")
expect(warnings).toHaveLength(0)
})
it("should detect multiple subshell patterns", () => {
const warnings = detectSecurityIssues("echo $(date) && echo `whoami`")
expect(warnings).toHaveLength(1) // Should still be 1 warning for subshell presence
expect(warnings[0].type).toBe("subshell")
})
it("should detect subshells in quoted strings", () => {
const warnings = detectSecurityIssues('echo "Current user: $(whoami)"')
expect(warnings).toHaveLength(1)
expect(warnings[0].type).toBe("subshell")
})
})
describe("security integration with extractCommandPatterns", () => {
it("should not include subshell content in patterns", () => {
const patterns = extractCommandPatterns("echo $(malicious)")
expect(patterns).toContain("echo")
expect(patterns).not.toContain("$(malicious)")
expect(patterns).not.toContain("malicious")
})
it("should handle commands with subshells properly", () => {
const patterns = extractCommandPatterns("npm install && echo $(whoami)")
expect(patterns).toContain("npm")
expect(patterns).toContain("npm install")
expect(patterns).toContain("echo")
expect(patterns).not.toContain("whoami")
})
it("should extract patterns from commands with backtick subshells", () => {
const patterns = extractCommandPatterns("git commit -m `date`")
expect(patterns).toContain("git")
expect(patterns).toContain("git commit")
expect(patterns).not.toContain("date")
})
})
describe("integration: parseCommandAndOutput with extractCommandPatterns", () => {
it("should not extract patterns from output text", () => {
const text = `wc -l *.go *.java

View file

@ -1,289 +1,67 @@
import { parse } from "shell-quote"
type ShellToken = string | { op: string } | { command: string }
/**
* Shared command parsing utility that consolidates parsing logic
* from both command-validation.ts and commandPatterns.ts
*/
/**
* Parse a command string and handle special cases like subshells,
* redirections, and quoted strings.
*
* @param command - The command string to parse
* @returns Object containing parsed information
*/
export function parseCommandString(command: string): {
subCommands: string[]
hasSubshells: boolean
subshellCommands: string[]
} {
if (!command?.trim()) {
return {
subCommands: [],
hasSubshells: false,
subshellCommands: [],
}
}
try {
// First split by newlines (including all types: \n, \r\n, \r) to handle multi-line commands
const lines = command.split(/\r\n|\r|\n/)
const allCommands: string[] = []
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine) continue // Skip empty lines
// Storage for replaced content
const redirections: string[] = []
const subshells: string[] = []
const quotes: string[] = []
const arrayIndexing: string[] = []
const arithmeticExpressions: string[] = []
const variables: string[] = []
// First handle PowerShell redirections by temporarily replacing them
let processedCommand = trimmedLine.replace(/\d*>&\d*/g, (match) => {
redirections.push(match)
return `__REDIR_${redirections.length - 1}__`
})
// Handle arithmetic expressions: $((...)) pattern
// Match the entire arithmetic expression including nested parentheses
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
arithmeticExpressions.push(match)
return `__ARITH_${arithmeticExpressions.length - 1}__`
})
// Handle array indexing expressions: ${array[...]} pattern and partial expressions
processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => {
arrayIndexing.push(match)
return `__ARRAY_${arrayIndexing.length - 1}__`
})
// Handle simple variable references: $varname pattern
// This prevents shell-quote from splitting $count into separate tokens
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
variables.push(match)
return `__VAR_${variables.length - 1}__`
})
// Then handle subshell commands - store them for security analysis
const _hasSubshells = trimmedLine.includes("$(") || trimmedLine.includes("`")
processedCommand = processedCommand
.replace(/\$\(((?!\().*?)\)/g, (_, inner) => {
const trimmedInner = inner.trim()
subshells.push(trimmedInner)
return `__SUBSH_${subshells.length - 1}__`
})
.replace(/`(.*?)`/g, (_, inner) => {
const trimmedInner = inner.trim()
subshells.push(trimmedInner)
return `__SUBSH_${subshells.length - 1}__`
})
// Then handle quoted strings
processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
quotes.push(match)
return `__QUOTE_${quotes.length - 1}__`
})
const tokens = parse(processedCommand) as ShellToken[]
const commands: string[] = []
let currentCommand: string[] = []
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (typeof token === "object" && "op" in token) {
// Chain operator - split command
if (["&&", "||", ";", "|"].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
} else {
// Other operators (>, &) are part of the command
currentCommand.push(token.op)
}
} else if (typeof token === "string") {
// Check if it's a subshell placeholder
const subshellMatch = token.match(/__SUBSH_(\d+)__/)
if (subshellMatch) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
commands.push(subshells[parseInt(subshellMatch[1])])
} else {
currentCommand.push(token)
}
}
}
// Add any remaining command
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
}
// Restore quotes, redirections, arithmetic expressions, variables, and array indexing
const restoredCommands = commands.map((cmd) => {
let result = cmd
// Restore quotes
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
// Restore redirections
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
// Restore arithmetic expressions
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
// Restore variables
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
// Restore array indexing expressions
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
return result
})
allCommands.push(...restoredCommands)
}
// Check if any line has subshells
const hasSubshells = command.includes("$(") || command.includes("`")
const subshellCommands: string[] = []
// Extract subshell commands for security analysis
let match: RegExpExecArray | null
const subshellRegex1 = /\$\(((?!\().*?)\)/g
const subshellRegex2 = /`(.*?)`/g
while ((match = subshellRegex1.exec(command)) !== null) {
if (match[1]) {
subshellCommands.push(match[1].trim())
}
}
while ((match = subshellRegex2.exec(command)) !== null) {
if (match[1]) {
subshellCommands.push(match[1].trim())
}
}
return {
subCommands: allCommands,
hasSubshells,
subshellCommands,
}
} catch (_error) {
// If shell-quote fails, fall back to simple splitting
const fallbackCommands = command
.split(/\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
return {
subCommands: fallbackCommands.length > 0 ? fallbackCommands : [command],
hasSubshells: command.includes("$(") || command.includes("`"),
subshellCommands: [],
}
}
}
/**
* Extract command patterns for permission management.
* This is a simplified version that focuses on extracting
* the main command and its subcommands for pattern matching.
*
* @param command - The command string to extract patterns from
* @returns Array of command patterns
* Extract command patterns from a command string.
* Returns at most 3 levels: base command, command + first argument, and command + first two arguments.
* Stops at flags (-), paths (/\~), file extensions (.ext), or special characters (:).
*/
export function extractPatternsFromCommand(command: string): string[] {
if (!command?.trim()) return []
// First, remove subshells for security - we don't want to extract patterns from subshell contents
const cleanedCommand = command
.replace(/\$\([^)]*\)/g, "") // Remove $() subshells
.replace(/`[^`]*`/g, "") // Remove backtick subshells
const patterns = new Set<string>()
const parsed = parse(cleanedCommand) as ShellToken[]
const commandSeparators = new Set(["|", "&&", "||", ";"])
let current: string[] = []
try {
const parsed = parse(command)
const commandSeparators = new Set(["|", "&&", "||", ";"])
let currentTokens: string[] = []
for (const token of parsed) {
if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) {
if (current.length) processCommandForPatterns(current, patterns)
current = []
} else {
current.push(String(token))
for (const token of parsed) {
if (typeof token === "object" && "op" in token && commandSeparators.has(token.op)) {
// Process accumulated tokens as a command
if (currentTokens.length > 0) {
extractFromTokens(currentTokens, patterns)
currentTokens = []
}
} else if (typeof token === "string") {
currentTokens.push(token)
}
}
}
if (current.length) processCommandForPatterns(current, patterns)
// Process any remaining tokens
if (currentTokens.length > 0) {
extractFromTokens(currentTokens, patterns)
}
} catch (_error) {
// Fallback: just extract the first word
const firstWord = command.trim().split(/\s+/)[0]
if (firstWord) patterns.add(firstWord)
}
return Array.from(patterns).sort()
}
/**
* Process a single command to extract patterns
*/
function processCommandForPatterns(cmd: string[], patterns: Set<string>): void {
if (!cmd.length || typeof cmd[0] !== "string") return
function isValidToken(token: string): boolean {
return !!token && !token.match(/[/\\~:]/) && token !== "." && !token.match(/\.\w+$/)
}
const mainCmd = cmd[0]
function extractFromTokens(tokens: string[], patterns: Set<string>): void {
if (tokens.length === 0) return
// Skip if it's just a number (like "0" from "0 total")
const mainCmd = tokens[0]
// Skip numeric commands like "0" from "0 total"
if (/^\d+$/.test(mainCmd)) return
// Skip common output patterns that aren't commands
const skipWords = ["total", "error", "warning", "failed", "success", "done"]
if (skipWords.includes(mainCmd.toLowerCase())) return
// Build patterns progressively up to 3 levels
let pattern = mainCmd
patterns.add(pattern)
patterns.add(mainCmd)
const breakingExps = [/^-/, /[\\/.~]/]
for (let i = 1; i < cmd.length; i++) {
const arg = cmd[i]
if (typeof arg !== "string" || breakingExps.some((re) => re.test(arg))) break
const pattern = cmd.slice(0, i + 1).join(" ")
patterns.add(pattern)
for (let i = 1; i < Math.min(tokens.length, 3); i++) {
if (isValidToken(tokens[i])) {
pattern += ` ${tokens[i]}`
patterns.add(pattern)
} else {
break // Stop at first invalid token
}
}
}
/**
* Security analysis for commands
*/
export interface SecurityWarning {
type: "subshell" | "injection"
message: string
}
/**
* Detect security issues in a command
*
* @param command - The command to analyze
* @returns Array of security warnings
*/
export function detectCommandSecurityIssues(command: string): SecurityWarning[] {
const warnings: SecurityWarning[] = []
// Check for subshell execution attempts
if (command.includes("$(") || command.includes("`")) {
warnings.push({
type: "subshell",
message: "Command contains subshell execution which could bypass restrictions",
})
}
return warnings
}

View file

@ -1,4 +1,4 @@
import { parseCommandString } from "./command-parser"
import { parse } from "shell-quote"
/**
* # Command Denylist Feature - Longest Prefix Match Strategy
@ -68,8 +68,40 @@ import { parseCommandString } from "./command-parser"
* - Newlines as command separators
*/
export function parseCommand(command: string): string[] {
const { subCommands } = parseCommandString(command)
return subCommands
if (!command?.trim()) return []
try {
const parsed = parse(command)
const commands: string[] = []
let currentCommand: string[] = []
for (const token of parsed) {
if (typeof token === "object" && "op" in token) {
// Chain operator - split command
if (["&&", "||", ";", "|"].includes(token.op)) {
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
currentCommand = []
}
} else {
// Other operators are part of the command
currentCommand.push(token.op)
}
} else if (typeof token === "string") {
currentCommand.push(token)
}
}
// Add any remaining command
if (currentCommand.length > 0) {
commands.push(currentCommand.join(" "))
}
return commands
} catch (_error) {
// If shell-quote fails, fall back to simple splitting
return [command]
}
}
/**

View file

@ -1,21 +1,14 @@
import { extractPatternsFromCommand, detectCommandSecurityIssues, SecurityWarning } from "./command-parser"
import { extractPatternsFromCommand } from "./command-parser"
export interface CommandPattern {
pattern: string
description?: string
}
// Re-export SecurityWarning type from command-parser
export type { SecurityWarning }
export function extractCommandPatterns(command: string): string[] {
return extractPatternsFromCommand(command)
}
export function detectSecurityIssues(command: string): SecurityWarning[] {
return detectCommandSecurityIssues(command)
}
/**
* Get a human-readable description for a command pattern.
* Simply returns the pattern followed by "commands".