mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Update list of default allowed commands (#7404)
This commit is contained in:
parent
296edfc829
commit
8684877097
3 changed files with 353 additions and 4 deletions
|
|
@ -321,9 +321,6 @@
|
|||
"type": "string"
|
||||
},
|
||||
"default": [
|
||||
"npm test",
|
||||
"npm install",
|
||||
"tsc",
|
||||
"git log",
|
||||
"git diff",
|
||||
"git show"
|
||||
|
|
|
|||
296
src/utils/__tests__/migrateSettings.spec.ts
Normal file
296
src/utils/__tests__/migrateSettings.spec.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { migrateSettings } from "../migrateSettings"
|
||||
|
||||
// Mock vscode module
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
createOutputChannel: vi.fn(() => ({
|
||||
appendLine: vi.fn(),
|
||||
})),
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock fs module
|
||||
vi.mock("fs/promises")
|
||||
|
||||
// Mock fs utils
|
||||
vi.mock("../fs", () => ({
|
||||
fileExistsAtPath: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock yaml module
|
||||
vi.mock("yaml", () => ({
|
||||
parse: vi.fn((content) => JSON.parse(content)),
|
||||
stringify: vi.fn((obj) => JSON.stringify(obj, null, 2)),
|
||||
}))
|
||||
|
||||
describe("migrateSettings", () => {
|
||||
let mockContext: any
|
||||
let mockOutputChannel: any
|
||||
let mockGlobalState: Map<string, any>
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a mock global state
|
||||
mockGlobalState = new Map()
|
||||
|
||||
// Create mock output channel
|
||||
mockOutputChannel = {
|
||||
appendLine: vi.fn(),
|
||||
}
|
||||
|
||||
// Create mock context
|
||||
mockContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => mockGlobalState.get(key)),
|
||||
update: vi.fn(async (key: string, value: any) => {
|
||||
mockGlobalState.set(key, value)
|
||||
}),
|
||||
},
|
||||
globalStorageUri: {
|
||||
fsPath: "/mock/storage/path",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("default commands migration", () => {
|
||||
it("should only run migration once", async () => {
|
||||
// Set up initial state with old default commands
|
||||
const initialCommands = ["npm install", "npm test", "tsc", "git log"]
|
||||
mockGlobalState.set("allowedCommands", initialCommands)
|
||||
|
||||
// Mock file system
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration first time
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Check that old default commands were removed
|
||||
expect(mockGlobalState.get("allowedCommands")).toEqual(["git log"])
|
||||
|
||||
// Check that migration was marked as complete
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true)
|
||||
|
||||
// Reset mocks but keep the migration flag
|
||||
mockGlobalState.set("defaultCommandsMigrationCompleted", true)
|
||||
mockGlobalState.set("allowedCommands", ["npm install", "npm test"])
|
||||
vi.mocked(mockContext.globalState.update).mockClear()
|
||||
|
||||
// Run migration again
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Verify commands were NOT modified the second time
|
||||
expect(mockGlobalState.get("allowedCommands")).toEqual(["npm install", "npm test"])
|
||||
expect(mockContext.globalState.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should remove npm install, npm test, and tsc from allowed commands", async () => {
|
||||
// Set up initial state with old default commands
|
||||
const initialCommands = ["git log", "npm install", "npm test", "tsc", "git diff", "echo hello"]
|
||||
mockGlobalState.set("allowedCommands", initialCommands)
|
||||
|
||||
// Mock file system to indicate no settings directory exists
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Check that old default commands were removed
|
||||
const updatedCommands = mockGlobalState.get("allowedCommands")
|
||||
expect(updatedCommands).toEqual(["git log", "git diff", "echo hello"])
|
||||
|
||||
// Verify the update was called
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("allowedCommands", [
|
||||
"git log",
|
||||
"git diff",
|
||||
"echo hello",
|
||||
])
|
||||
|
||||
// Verify migration was marked as complete
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true)
|
||||
|
||||
// No notification should be shown
|
||||
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not remove commands with arguments (only exact matches)", async () => {
|
||||
// Set up initial state with commands that have arguments
|
||||
const initialCommands = [
|
||||
"npm install express",
|
||||
"npm test --coverage",
|
||||
"tsc --watch",
|
||||
"npm list",
|
||||
"npm view",
|
||||
"yarn list",
|
||||
"git status",
|
||||
]
|
||||
mockGlobalState.set("allowedCommands", initialCommands)
|
||||
|
||||
// Mock file system
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Check that commands with arguments were NOT removed (only exact matches are removed)
|
||||
const updatedCommands = mockGlobalState.get("allowedCommands")
|
||||
expect(updatedCommands).toEqual([
|
||||
"npm install express",
|
||||
"npm test --coverage",
|
||||
"tsc --watch",
|
||||
"npm list",
|
||||
"npm view",
|
||||
"yarn list",
|
||||
"git status",
|
||||
])
|
||||
|
||||
// Migration should still be marked as complete
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true)
|
||||
|
||||
// No notification should be shown
|
||||
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle case-insensitive matching", async () => {
|
||||
// Set up initial state with mixed case commands
|
||||
const initialCommands = ["NPM INSTALL", "Npm Test", "TSC", "git log"]
|
||||
mockGlobalState.set("allowedCommands", initialCommands)
|
||||
|
||||
// Mock file system
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Check that unsafe commands were removed regardless of case
|
||||
const updatedCommands = mockGlobalState.get("allowedCommands")
|
||||
expect(updatedCommands).toEqual(["git log"])
|
||||
})
|
||||
|
||||
it("should not modify commands if no old defaults are present", async () => {
|
||||
// Set up initial state with only safe commands
|
||||
const initialCommands = ["git log", "git diff", "ls -la", "echo hello"]
|
||||
mockGlobalState.set("allowedCommands", initialCommands)
|
||||
|
||||
// Mock file system
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Check that commands remain unchanged
|
||||
const updatedCommands = mockGlobalState.get("allowedCommands")
|
||||
expect(updatedCommands).toEqual(initialCommands)
|
||||
|
||||
// Verify no notification was shown
|
||||
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
|
||||
|
||||
// Verify migration was still marked as complete
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true)
|
||||
})
|
||||
|
||||
it("should handle missing or invalid allowedCommands gracefully", async () => {
|
||||
// Test with no allowedCommands set
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
// Should still mark migration as complete
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true)
|
||||
|
||||
// Verify appropriate log messages
|
||||
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining("marking migration as complete"),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle non-array allowedCommands gracefully", async () => {
|
||||
// Test with non-array value
|
||||
mockGlobalState.set("allowedCommands", "not an array")
|
||||
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Should still mark migration as complete
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true)
|
||||
|
||||
// Verify appropriate log messages
|
||||
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining("marking migration as complete"),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
// Set up state
|
||||
mockGlobalState.set("allowedCommands", ["npm install"])
|
||||
|
||||
// Make update throw an error
|
||||
mockContext.globalState.update = vi.fn().mockRejectedValue(new Error("Update failed"))
|
||||
|
||||
// Mock file system
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration - should not throw
|
||||
await expect(migrateSettings(mockContext, mockOutputChannel)).resolves.toBeUndefined()
|
||||
|
||||
// Verify error was logged
|
||||
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[Default Commands Migration] Error"),
|
||||
)
|
||||
})
|
||||
|
||||
it("should only remove exact matches, not commands with arguments", async () => {
|
||||
// Set up initial state with exact matches and commands with arguments
|
||||
const initialCommands = [
|
||||
"npm install", // exact match - should be removed
|
||||
"npm install --save-dev typescript", // has arguments - should NOT be removed
|
||||
"npm test", // exact match - should be removed
|
||||
"npm test --coverage", // has arguments - should NOT be removed
|
||||
"tsc", // exact match - should be removed
|
||||
"tsc --noEmit", // has arguments - should NOT be removed
|
||||
"git log --oneline",
|
||||
]
|
||||
mockGlobalState.set("allowedCommands", initialCommands)
|
||||
|
||||
// Mock file system
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
|
||||
|
||||
// Run migration
|
||||
await migrateSettings(mockContext, mockOutputChannel)
|
||||
|
||||
// Check that only exact matches were removed
|
||||
const updatedCommands = mockGlobalState.get("allowedCommands")
|
||||
expect(updatedCommands).toEqual([
|
||||
"npm install --save-dev typescript",
|
||||
"npm test --coverage",
|
||||
"tsc --noEmit",
|
||||
"git log --oneline",
|
||||
])
|
||||
|
||||
// No notification should be shown
|
||||
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -8,7 +8,7 @@ import * as yaml from "yaml"
|
|||
const deprecatedCustomModesJSONFilename = "custom_modes.json"
|
||||
|
||||
/**
|
||||
* Migrates old settings files to new file names
|
||||
* Migrates old settings files to new file names and removes commands from old defaults
|
||||
*
|
||||
* TODO: Remove this migration code in September 2025 (6 months after implementation)
|
||||
*/
|
||||
|
|
@ -16,6 +16,8 @@ export async function migrateSettings(
|
|||
context: vscode.ExtensionContext,
|
||||
outputChannel: vscode.OutputChannel,
|
||||
): Promise<void> {
|
||||
// First, migrate commands from old defaults (security fix)
|
||||
await migrateDefaultCommands(context, outputChannel)
|
||||
// Legacy file names that need to be migrated to the new names in GlobalFileNames
|
||||
const fileMigrations = [
|
||||
// custom_modes.json to custom_modes.yaml is handled separately below
|
||||
|
|
@ -113,3 +115,57 @@ async function migrateCustomModesToYaml(settingsDir: string, outputChannel: vsco
|
|||
outputChannel.appendLine(`Error reading custom_modes.json: ${fileError}. Skipping migration.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes commands from old defaults that could execute arbitrary code
|
||||
* This addresses the security vulnerability where npm install/test can run malicious postinstall scripts
|
||||
*/
|
||||
async function migrateDefaultCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
outputChannel: vscode.OutputChannel,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if this migration has already been run
|
||||
const migrationKey = "defaultCommandsMigrationCompleted"
|
||||
if (context.globalState.get(migrationKey)) {
|
||||
outputChannel.appendLine("[Default Commands Migration] Migration already completed, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
const allowedCommands = context.globalState.get<string[]>("allowedCommands")
|
||||
|
||||
if (!allowedCommands || !Array.isArray(allowedCommands)) {
|
||||
// Mark migration as complete even if no commands to migrate
|
||||
await context.globalState.update(migrationKey, true)
|
||||
outputChannel.appendLine("No allowed commands found in global state, marking migration as complete")
|
||||
return
|
||||
}
|
||||
|
||||
// Only migrate the specific commands that were removed from the defaults
|
||||
const oldDefaultCommands = ["npm install", "npm test", "tsc"]
|
||||
|
||||
// Filter out old default commands (case-insensitive exact match only)
|
||||
const originalLength = allowedCommands.length
|
||||
const filteredCommands = allowedCommands.filter((cmd) => {
|
||||
const cmdLower = cmd.toLowerCase().trim()
|
||||
return !oldDefaultCommands.some((oldDefault) => cmdLower === oldDefault.toLowerCase())
|
||||
})
|
||||
|
||||
if (filteredCommands.length < originalLength) {
|
||||
const removedCount = originalLength - filteredCommands.length
|
||||
await context.globalState.update("allowedCommands", filteredCommands)
|
||||
|
||||
outputChannel.appendLine(
|
||||
`[Default Commands Migration] Removed ${removedCount} command(s) from old defaults to prevent arbitrary code execution vulnerability`,
|
||||
)
|
||||
} else {
|
||||
outputChannel.appendLine("[Default Commands Migration] No old default commands found in allowed list")
|
||||
}
|
||||
|
||||
// Mark migration as complete
|
||||
await context.globalState.update(migrationKey, true)
|
||||
outputChannel.appendLine("[Default Commands Migration] Migration marked as complete")
|
||||
} catch (error) {
|
||||
outputChannel.appendLine(`[Default Commands Migration] Error migrating default commands: ${error}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue