From 98781d7862dadf04754203689096c64e485e5588 Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Thu, 11 Sep 2025 12:59:42 -0500 Subject: [PATCH 1/8] security: squashed all security middleware commits by Enrique Trevino --- packages/types/src/experiment.ts | 3 +- packages/types/src/global-settings.ts | 1 + src/core/security/SecurityGuard.ts | 1062 +++++++++++++++++ .../security/__tests__/SecurityGuard.spec.ts | 923 ++++++++++++++ .../ask-only.yaml | 27 + .../block-only.yaml | 39 + .../edge-cases.yaml | 38 + .../mixed-rules.yaml | 44 + .../fixtures/test-configs/ask-only.yaml | 27 + .../fixtures/test-configs/block-only.yaml | 39 + .../fixtures/test-configs/edge-cases.yaml | 38 + .../fixtures/test-configs/mixed-rules.yaml | 44 + .../security/__tests__/helpers/test-utils.ts | 72 ++ src/core/security/index.ts | 1 + src/core/task/Task.ts | 117 +- src/core/task/__tests__/Task.spec.ts | 7 +- .../executeCommandTimeout.integration.spec.ts | 3 + src/core/tools/applyDiffTool.ts | 18 + src/core/tools/executeCommandTool.ts | 39 +- src/core/tools/insertContentTool.ts | 18 + src/core/tools/multiApplyDiffTool.ts | 24 + src/core/tools/readFileTool.ts | 54 +- src/core/tools/searchAndReplaceTool.ts | 18 + src/core/tools/writeToFileTool.ts | 71 +- src/core/webview/ClineProvider.ts | 3 + src/core/webview/webviewMessageHandler.ts | 59 + src/shared/ExtensionMessage.ts | 10 + src/shared/WebviewMessage.ts | 5 + src/shared/__tests__/experiments.spec.ts | 12 + src/shared/experiments.ts | 2 + webview-ui/src/components/chat/ChatView.tsx | 15 +- .../settings/ExperimentalSettings.tsx | 206 +++- .../src/components/settings/SettingsView.tsx | 2 + .../src/context/ExtensionStateContext.tsx | 8 + .../__tests__/ExtensionStateContext.spec.tsx | 2 + webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 53 files changed, 3091 insertions(+), 32 deletions(-) create mode 100644 src/core/security/SecurityGuard.ts create mode 100644 src/core/security/__tests__/SecurityGuard.spec.ts create mode 100644 src/core/security/__tests__/fixtures/security-middleware-test-configs/ask-only.yaml create mode 100644 src/core/security/__tests__/fixtures/security-middleware-test-configs/block-only.yaml create mode 100644 src/core/security/__tests__/fixtures/security-middleware-test-configs/edge-cases.yaml create mode 100644 src/core/security/__tests__/fixtures/security-middleware-test-configs/mixed-rules.yaml create mode 100644 src/core/security/__tests__/fixtures/test-configs/ask-only.yaml create mode 100644 src/core/security/__tests__/fixtures/test-configs/block-only.yaml create mode 100644 src/core/security/__tests__/fixtures/test-configs/edge-cases.yaml create mode 100644 src/core/security/__tests__/fixtures/test-configs/mixed-rules.yaml create mode 100644 src/core/security/__tests__/helpers/test-utils.ts create mode 100644 src/core/security/index.ts diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 10384db8ed..e5f734d547 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,7 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = ["powerSteering", "multiFileApplyDiff"] as const +export const experimentIds = ["powerSteering", "multiFileApplyDiff", "securityMiddleware"] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -19,6 +19,7 @@ export type ExperimentId = z.infer export const experimentsSchema = z.object({ powerSteering: z.boolean().optional(), multiFileApplyDiff: z.boolean().optional(), + securityMiddleware: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 30521f2c68..1dba333fc0 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -62,6 +62,7 @@ export const globalSettingsSchema = z.object({ alwaysAllowFollowupQuestions: z.boolean().optional(), followupAutoApproveTimeoutMs: z.number().optional(), alwaysAllowUpdateTodoList: z.boolean().optional(), + securityCustomConfigPath: z.string().optional(), allowedCommands: z.array(z.string()).optional(), deniedCommands: z.array(z.string()).optional(), commandExecutionTimeout: z.number().optional(), diff --git a/src/core/security/SecurityGuard.ts b/src/core/security/SecurityGuard.ts new file mode 100644 index 0000000000..598ff40690 --- /dev/null +++ b/src/core/security/SecurityGuard.ts @@ -0,0 +1,1062 @@ +import * as fs from "fs" +import * as yaml from "yaml" +import * as os from "os" +import * as path from "path" + +export interface SecurityResult { + blocked?: boolean + requiresApproval?: boolean + message: string + pattern: string + violationType?: "file" | "command" | "env_var" + ruleType?: "block" | "ask" + matchedRule?: string + context?: string +} + +interface SecurityConfiguration { + block?: { + files?: string[] + env_vars?: string[] + commands?: string[] + } + ask?: { + files?: string[] + env_vars?: string[] + commands?: string[] + } +} + +/** + * SecurityGuard - Controls AI access to confidential and sensitive files + */ +export class SecurityGuard { + private cwd: string + private isEnabled: boolean + private customConfigPath?: string + private confidentialFiles: string[] = [] + private sensitiveFiles: string[] = [] + private confidentialEnvVars: string[] = [] + private confidentialCommands: string[] = [] + private sensitiveCommands: string[] = [] + private ruleIndex: Map = new Map() + + constructor(cwd: string, isEnabled: boolean = false, customConfigPath?: string) { + console.log( + `[SecurityGuard] Constructor called - cwd: "${cwd}", isEnabled: ${isEnabled}, customConfigPath: "${customConfigPath || "none"}"`, + ) + + this.cwd = cwd + this.isEnabled = isEnabled + this.customConfigPath = customConfigPath + + if (this.isEnabled) { + console.log("[SecurityGuard] Security is enabled, calling loadConfiguration()") + this.loadConfiguration() + } else { + console.log("[SecurityGuard] Security is disabled, skipping loadConfiguration()") + } + } + + /** + * Load security configuration from hierarchical YAML files + * Uses BLOCK-always-wins merging strategy + */ + private loadConfiguration(): void { + try { + const globalConfig = this.loadConfigFile(this.getGlobalConfigPath()) + const projectConfig = this.loadConfigFile(this.getProjectConfigPath()) + + let customConfig = {} + if (this.customConfigPath) { + console.log(`[SecurityGuard] Processing custom config path: "${this.customConfigPath}"`) + + // Skip disabled custom configs + if (this.customConfigPath.startsWith("DISABLED:")) { + console.log("[SecurityGuard] Custom config is disabled, skipping") + customConfig = {} + } else { + // Resolve custom config path to handle ~ and relative paths + let resolvedCustomPath = this.customConfigPath + + // Expand ~ to home directory + if (resolvedCustomPath.startsWith("~")) { + console.log(`[SecurityGuard] Expanding ~ in path: "${resolvedCustomPath}"`) + resolvedCustomPath = resolvedCustomPath.replace("~", os.homedir()) + console.log(`[SecurityGuard] After ~ expansion: "${resolvedCustomPath}"`) + } + + // Resolve relative paths to absolute paths + if (!path.isAbsolute(resolvedCustomPath)) { + console.log(`[SecurityGuard] Resolving relative path: "${resolvedCustomPath}"`) + resolvedCustomPath = path.resolve(resolvedCustomPath) + console.log(`[SecurityGuard] After path resolution: "${resolvedCustomPath}"`) + } + + console.log(`[SecurityGuard] Final resolved custom config path: "${resolvedCustomPath}"`) + customConfig = this.loadConfigFile(resolvedCustomPath) + } + } else { + console.log("[SecurityGuard] No custom config path provided") + } + + this.mergeConfigurations(globalConfig, projectConfig, customConfig) + this.buildRuleIndex() + } catch (error) { + this.loadLegacyConfiguration() + } + } + + private getGlobalConfigPath(): string { + return path.join(os.homedir(), ".roo", "security.yaml") + } + + private getProjectConfigPath(): string { + return path.join(this.cwd, ".roo", "security.yaml") + } + + /** + * Get config file status for UI display + */ + public getConfigStatus(): { + globalPath: string + globalExists: boolean + projectPath: string + projectExists: boolean + customPath?: string + customExists?: boolean + } { + const globalPath = this.getGlobalConfigPath() + const projectPath = this.getProjectConfigPath() + + return { + globalPath, + globalExists: fs.existsSync(globalPath), + projectPath, + projectExists: fs.existsSync(projectPath), + customPath: this.customConfigPath, + customExists: this.customConfigPath ? fs.existsSync(this.customConfigPath) : undefined, + } + } + private loadConfigFile(configPath: string): SecurityConfiguration { + try { + console.log(`[SecurityGuard] Attempting to load config: ${configPath}`) + + if (!fs.existsSync(configPath)) { + console.log(`[SecurityGuard] Config file does not exist: ${configPath}`) + // No auto-creation - user must explicitly create config files via UI buttons + return {} + } + + console.log(`[SecurityGuard] Config file exists, reading: ${configPath}`) + const yamlContent = fs.readFileSync(configPath, "utf8") + console.log(`[SecurityGuard] Read ${yamlContent.length} characters from: ${configPath}`) + + const config = yaml.parse(yamlContent) as SecurityConfiguration + console.log( + `[SecurityGuard] Successfully parsed config from: ${configPath}`, + JSON.stringify(config, null, 2), + ) + + return config + } catch (error) { + console.error(`[SecurityGuard] Error loading config from ${configPath}:`, error) + return {} + } + } + + private mergeConfigurations( + global: SecurityConfiguration, + project: SecurityConfiguration, + custom: SecurityConfiguration = {}, + ): void { + console.log("[SecurityGuard] Starting mergeConfigurations") + console.log("[SecurityGuard] Global config:", JSON.stringify(global, null, 2)) + console.log("[SecurityGuard] Project config:", JSON.stringify(project, null, 2)) + console.log("[SecurityGuard] Custom config:", JSON.stringify(custom, null, 2)) + + // Handle null/undefined configs by converting to empty objects + const safeGlobal = global || {} + const safeProject = project || {} + const safeCustom = custom || {} + + const allBlockFiles = [ + ...(safeGlobal.block?.files || []), + ...(safeProject.block?.files || []), + ...(safeCustom.block?.files || []), + ] + + const allBlockCommands = [ + ...(safeGlobal.block?.commands || []), + ...(safeProject.block?.commands || []), + ...(safeCustom.block?.commands || []), + ] + + const allBlockEnvVars = [ + ...(safeGlobal.block?.env_vars || []), + ...(safeProject.block?.env_vars || []), + ...(safeCustom.block?.env_vars || []), + ] + + const allAskFiles = [ + ...(safeGlobal.ask?.files || []), + ...(safeProject.ask?.files || []), + ...(safeCustom.ask?.files || []), + ] + + const allAskCommands = [ + ...(safeGlobal.ask?.commands || []), + ...(safeProject.ask?.commands || []), + ...(safeCustom.ask?.commands || []), + ] + + console.log("[SecurityGuard] Merged block files:", allBlockFiles) + console.log("[SecurityGuard] Merged block commands:", allBlockCommands) + console.log("[SecurityGuard] Merged ask files:", allAskFiles) + console.log("[SecurityGuard] Merged ask commands:", allAskCommands) + + this.confidentialFiles = [...new Set(allBlockFiles)] + this.confidentialCommands = [...new Set(allBlockCommands)] + this.confidentialEnvVars = [...new Set(allBlockEnvVars)] + this.sensitiveFiles = [...new Set(allAskFiles)] + this.sensitiveCommands = [...new Set(allAskCommands)] + + console.log( + "[SecurityGuard] Before filtering - confidentialFiles:", + this.confidentialFiles.length, + "sensitiveFiles:", + this.sensitiveFiles.length, + ) + + this.sensitiveFiles = this.sensitiveFiles.filter((pattern) => !this.confidentialFiles.includes(pattern)) + this.sensitiveCommands = this.sensitiveCommands.filter( + (pattern) => !this.confidentialCommands.includes(pattern), + ) + + console.log( + "[SecurityGuard] After filtering - confidentialFiles:", + this.confidentialFiles.length, + "sensitiveFiles:", + this.sensitiveFiles.length, + ) + console.log("[SecurityGuard] Final confidentialFiles:", this.confidentialFiles) + console.log("[SecurityGuard] Final sensitiveFiles:", this.sensitiveFiles) + } + public static createDefaultGlobalConfig(): string { + const configPath = path.join(os.homedir(), ".roo", "security.yaml") + const defaultGlobalConfig = `# Roo Security Middleware - Global Configuration +# This file applies to ALL projects. +# Per-project rules can be defined in [file://./.roo/security.yaml] +# +# Syntax example: +# +# block: +# files: +# - '.env*' +# commands: +# - env +# env_vars: +# - '*_SECRET_ACCESS_KEY' + +override_global_config: true + +block: + files: + # - + commands: + # - + env_vars: + # - +# NOTE: there is a space after - so the user can uncomment current line and start typing a rule; no need for them to create a space first. + +ask: + files: + # - + commands: + # - + env_vars: + # - ` + + try { + const dir = path.dirname(configPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(configPath, defaultGlobalConfig) + return configPath + } catch (error) { + // Config creation failed - continue silently + return configPath + } + } + + public static createDefaultProjectConfig(workspacePath: string): string { + const configPath = path.join(workspacePath, ".roo", "security.yaml") + const defaultProjectConfig = `# Roo Security Middleware - Project Configuration +# This file applies to THIS PROJECT only. +# Global rules are defined in [file://~/.roo/security.yaml] +# +# Syntax example: +# +# ask: +# files: +# - 'config/*.json' +# - 'staging.*' +# commands: +# - npm + +override_global_config: false + +block: + files: + # - + commands: + # - + env_vars: + # - +# NOTE: there is a space after - so the user can uncomment current line and start typing a rule; no need for them to create a space first. + +ask: + files: + # - + commands: + # - + env_vars: + # - ` + + try { + const dir = path.dirname(configPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(configPath, defaultProjectConfig) + return configPath + } catch (error) { + // Config creation failed - continue silently + return configPath + } + } + + private loadLegacyConfiguration(): void { + this.confidentialFiles = [] + this.sensitiveFiles = [] + this.confidentialEnvVars = [] + this.confidentialCommands = [] + this.sensitiveCommands = [] + this.buildRuleIndex() + } + + /** + * Validate file access - returns security result if file needs protection + */ + validateFileAccess(filePath: string): SecurityResult | null { + if (!this.isEnabled) return null // Early return when disabled + + const normalizedPath = this.normalizePath(filePath) + const basename = this.getBasename(normalizedPath) + + // Check confidential files (complete block) + for (const pattern of this.confidentialFiles) { + if (this.matchesPattern(normalizedPath, pattern) || this.matchesPattern(basename, pattern)) { + return this.createFileSecurityResult({ + blocked: true, + message: `Access denied to confidential file: ${filePath}`, + pattern, + ruleType: "block", + filePath, + context: `Direct file access blocked: ${filePath}`, + }) + } + } + + // Check sensitive files (user approval required) + for (const pattern of this.sensitiveFiles) { + if (this.matchesPattern(normalizedPath, pattern) || this.matchesPattern(basename, pattern)) { + return this.createFileSecurityResult({ + requiresApproval: true, + message: `Allow access to sensitive file '${filePath}'? (Pattern: ${pattern})`, + pattern, + ruleType: "ask", + filePath, + context: `Direct file access requires approval: ${filePath}`, + }) + } + } + + return null + } + + /** + * Validate command execution - returns security result if command needs protection + */ + validateCommand(command: string): SecurityResult | null { + if (!this.isEnabled) return null // Early return when disabled + + const trimmedCommand = command.trim() + const parts = trimmedCommand.split(/\s+/) + const baseCommand = parts[0]?.toLowerCase() || "" + + // PRIORITY 1: Check confidential commands (complete block) + for (const pattern of this.confidentialCommands) { + if (baseCommand === pattern.toLowerCase()) { + return this.createCommandSecurityResult({ + blocked: true, + message: `Command blocked: Confidential command '${baseCommand}' not allowed`, + pattern, + ruleType: "block", + command: trimmedCommand, + context: `Confidential command blocked: ${baseCommand}`, + }) + } + } + + // PRIORITY 2: Scan entire command for BLOCKED file patterns FIRST (catches most bypasses) + // This must come BEFORE sensitive command checking to ensure blocked files are never accessible + const filePatternViolation = this.scanCommandForFilePatterns(trimmedCommand) + if (filePatternViolation) { + return filePatternViolation + } + + // PRIORITY 3: Check sensitive commands (user approval required) - ONLY after file blocking + for (const pattern of this.sensitiveCommands) { + if (baseCommand === pattern.toLowerCase()) { + return this.createCommandSecurityResult({ + requiresApproval: true, + message: `Allow execution of sensitive command '${baseCommand}'? Command: ${trimmedCommand}`, + pattern, + ruleType: "ask", + command: trimmedCommand, + context: `Sensitive command requires approval: ${baseCommand}`, + }) + } + } + + // Check scripting languages with code execution + const scriptingLanguages = ["python", "python3", "ruby", "perl", "node", "nodejs", "php"] + if (scriptingLanguages.includes(baseCommand)) { + const codeViolation = this.validateScriptingLanguageCommand(trimmedCommand, baseCommand) + if (codeViolation) { + return codeViolation + } + } + + // Check find command with -exec parameter + if (baseCommand === "find") { + const findViolation = this.validateFindCommandWithExecParameter(trimmedCommand) + if (findViolation) { + return findViolation + } + } + + // Check command chaining (&&, ||, ;, |) + const chainingViolation = this.validateCommandChaining(trimmedCommand) + if (chainingViolation) { + return chainingViolation + } + + // Check if command accesses sensitive files (original logic) + const fileAccessingCommands = [ + "cat", + "less", + "more", + "head", + "tail", + "grep", + "awk", + "sed", + "get-content", + "gc", + "type", + "select-string", + "sls", + ] + + if (!fileAccessingCommands.includes(baseCommand)) return null + + // Check each argument that could be a file path + for (let i = 1; i < parts.length; i++) { + const arg = parts[i] + + // Skip command flags/options + if (arg.startsWith("-") || arg.startsWith("/") || arg.includes(":")) { + continue + } + + // Check if this argument is a sensitive file + const fileCheck = this.validateFileAccess(arg) + if (fileCheck?.blocked) { + return { + blocked: true, + message: `Command blocked: Access denied to confidential file '${arg}'`, + pattern: fileCheck.pattern, + } + } + + if (fileCheck?.requiresApproval) { + return { + requiresApproval: true, + message: `Allow command accessing sensitive file '${arg}'? Command: ${trimmedCommand}`, + pattern: fileCheck.pattern, + } + } + } + + return null + } + + /** + * Scan command arguments for confidential file patterns + * Simple YAML-driven pattern matching only - follows the YAML, nothing else + */ + private scanCommandForFilePatterns(command: string): SecurityResult | null { + // Parse command to separate command name from file arguments + const parts = command.split(/\s+/) + + // Get potential file arguments (skip command name and flags) + const fileArgs = parts.slice(1).filter((arg) => { + // Skip flags and options + if (arg.startsWith("-") || arg.startsWith("/")) { + return false + } + // Skip arguments that look like URLs or contain colons (likely not file paths) + if (arg.includes("://") || (arg.includes(":") && !arg.includes("/"))) { + return false + } + return true + }) + + // Check for confidential file patterns in file arguments only + for (const pattern of this.confidentialFiles) { + for (const arg of fileArgs) { + if (this.matchesPattern(arg, pattern)) { + return this.createSecurityResult({ + blocked: true, + message: `Command blocked: Access denied to confidential file pattern '${pattern}'`, + pattern, + violationType: "file", + ruleType: "block", + context: `File argument in command: ${arg}`, + }) + } + } + } + + // Check for sensitive file patterns in file arguments only + for (const pattern of this.sensitiveFiles) { + for (const arg of fileArgs) { + if (this.matchesPattern(arg, pattern)) { + return { + requiresApproval: true, + message: `Allow command accessing sensitive file pattern '${pattern}'? Command: ${command}`, + pattern, + } + } + } + } + + return null + } + + /** + * Validate scripting language commands with code execution + */ + private validateScriptingLanguageCommand(command: string, language: string): SecurityResult | null { + // First, check for file patterns anywhere in the command (catches most cases) + const filePatternViolation = this.scanCommandForFilePatterns(command) + if (filePatternViolation) { + return filePatternViolation + } + + // Parse command more carefully to handle quoted strings + const parts = this.parseCommandWithQuotes(command) + + // Look for code execution parameters + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + + // Python: -c parameter + if ( + (language.includes("python") && part === "-c") || + // Ruby: -e parameter + (language === "ruby" && part === "-e") || + // Perl: -e or -ne parameters + (language === "perl" && (part === "-e" || part === "-ne")) || + // Node: -e or --eval parameters + ((language === "node" || language === "nodejs") && (part === "-e" || part === "--eval")) || + // PHP: -r parameter + (language === "php" && part === "-r") + ) { + // Get the code string (next parameter) + const codeString = parts[i + 1] + if (codeString) { + const codeViolation = this.validateCodeString(codeString, language) + if (codeViolation) { + return codeViolation + } + } + } + } + + // Special case for Perl: check all remaining arguments after script parameters + if (language !== "perl") return null + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (part !== "-ne" && part !== "-e") continue + + // Check all arguments after the script parameter + for (let j = i + 2; j < parts.length; j++) { + const arg = parts[j] + if (arg.startsWith("-")) continue + + const fileCheck = this.validateFileAccess(arg) + if (fileCheck?.blocked) { + return { + blocked: true, + message: `Perl command blocked: Access denied to confidential file '${arg}'`, + pattern: fileCheck.pattern, + } + } + + if (fileCheck?.requiresApproval) { + return { + requiresApproval: true, + message: `Allow Perl command accessing sensitive file '${arg}'?`, + pattern: fileCheck.pattern, + } + } + } + } + + return null + } + + /** + * Validate find command with -exec parameter + */ + private validateFindCommandWithExecParameter(command: string): SecurityResult | null { + const parts = command.split(/\s+/) + + // Check -name parameter against YAML-configured patterns only + for (let i = 0; i < parts.length; i++) { + if (parts[i] !== "-name" || i + 1 >= parts.length) continue + + const namePattern = parts[i + 1].replace(/['"]/g, "") // Remove quotes + + // Check against confidential file patterns from YAML + for (const pattern of this.confidentialFiles) { + if (this.matchesPattern(namePattern, pattern)) { + return this.createSecurityResult({ + blocked: true, + message: `Find command blocked: Searching for confidential files '${namePattern}'`, + pattern, + violationType: "file", + ruleType: "block", + context: `Find command -name parameter: ${namePattern}`, + }) + } + } + + // Check against sensitive file patterns from YAML + for (const pattern of this.sensitiveFiles) { + if (this.matchesPattern(namePattern, pattern)) { + return this.createSecurityResult({ + requiresApproval: true, + message: `Allow find command searching for sensitive files '${namePattern}'?`, + pattern, + violationType: "file", + ruleType: "ask", + context: `Find command -name parameter: ${namePattern}`, + }) + } + } + } + + // Look for -exec parameter + for (let i = 0; i < parts.length; i++) { + if (parts[i] !== "-exec") continue + + // Get the command after -exec + const execCommand = parts + .slice(i + 1) + .join(" ") + .replace(/\s*\\\;\s*$/, "") + + // Check for file patterns in the exec command (avoid infinite recursion) + const filePatternViolation = this.scanCommandForFilePatterns(execCommand) + if (filePatternViolation) { + return { + blocked: filePatternViolation.blocked, + requiresApproval: filePatternViolation.requiresApproval, + message: `Find command blocked: ${filePatternViolation.message}`, + pattern: filePatternViolation.pattern, + } + } + } + + return null + } + + /** + * Validate command chaining (&&, ||, ;, |) + */ + private validateCommandChaining(command: string): SecurityResult | null { + // Only check for chaining if command contains separators + const chainSeparators = /(\s*&&\s*|\s*\|\|\s*|\s*;\s*|\s*\|\s*)/ + if (!chainSeparators.test(command)) { + return null + } + + const commands = command.split(chainSeparators) + + // Validate each command in the chain (but avoid infinite recursion) + for (const cmd of commands) { + const trimmed = cmd.trim() + + // Skip separators and empty strings + if (!trimmed || chainSeparators.test(trimmed)) { + continue + } + + // Check for file patterns in chained commands (avoid full recursive validation) + const filePatternViolation = this.scanCommandForFilePatterns(trimmed) + if (filePatternViolation) { + return { + blocked: filePatternViolation.blocked, + requiresApproval: filePatternViolation.requiresApproval, + message: `Command chain blocked: ${filePatternViolation.message}`, + pattern: filePatternViolation.pattern, + } + } + } + + return null + } + + /** + * Validate code strings for file operations + */ + private validateCodeString(code: string, language: string): SecurityResult | null { + const lowerCode = code.toLowerCase() + + // Check for file operations in different languages + const fileOperations = [ + "open", + "file.read", + "file_get_contents", + "file", + "readfile", + "readfilesync", + "with open", + "file.open", + "io.read", + "fs.read", + ] + + for (const operation of fileOperations) { + if (!lowerCode.includes(operation.toLowerCase())) continue + + // Check for all confidential file patterns using YAML-driven patterns only + for (const pattern of this.confidentialFiles) { + const regex = this.patternToRegex(pattern) + if (regex.test(lowerCode)) { + return { + blocked: true, + message: `Code execution blocked: Access denied to confidential file pattern '${pattern}' in ${language} code`, + pattern, + } + } + } + + // Check for sensitive file patterns using YAML-driven patterns only + for (const pattern of this.sensitiveFiles) { + const regex = this.patternToRegex(pattern) + if (regex.test(lowerCode)) { + return { + requiresApproval: true, + message: `Allow ${language} code accessing sensitive file pattern '${pattern}'?`, + pattern, + } + } + } + } + + return null + } + + /** + * Parse command string handling quoted arguments properly + */ + private parseCommandWithQuotes(command: string): string[] { + const parts: string[] = [] + let current = "" + let inQuotes = false + let quoteChar = "" + + for (let i = 0; i < command.length; i++) { + const char = command[i] + + // Handle quote start + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true + quoteChar = char + current += char + continue + } + + // Handle quote end + if (inQuotes && char === quoteChar) { + inQuotes = false + current += char + quoteChar = "" + continue + } + + // Handle space outside quotes + if (!inQuotes && char === " ") { + if (current.trim()) { + parts.push(current.trim()) + current = "" + } + continue + } + + // Default: add character to current part + current += char + } + + if (current.trim()) { + parts.push(current.trim()) + } + + return parts + } + + /** + * Convert glob pattern to regex for string searching + */ + private patternToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/\./g, "\\.") // Escape dots + .replace(/\*/g, ".*") // Convert * to .* + + return new RegExp(escaped, "i") // Case insensitive + } + + /** + * Clean up resources + */ + dispose(): void { + // No resources to clean up currently + } + + /** + * Check if security is enabled + */ + isSecurityEnabled(): boolean { + return this.isEnabled + } + + /** + * Update enabled state (for runtime toggling) + */ + setEnabled(enabled: boolean): void { + this.isEnabled = enabled + if (enabled && this.confidentialFiles.length === 0 && this.sensitiveFiles.length === 0) { + this.loadConfiguration() + } + } + + /** + * Get basename of a file path (filename without directory) + */ + private getBasename(filePath: string): string { + if (!filePath) { + return "" + } + const parts = filePath.split("/") + return parts[parts.length - 1] || "" + } + + /** + * Normalize file path for consistent matching + */ + private normalizePath(filePath: string): string { + if (!filePath) { + return "" + } + + // Convert backslashes to forward slashes for consistent matching + let normalized = filePath.replace(/\\/g, "/") + + // Remove leading slash for relative path matching + if (normalized.startsWith("/")) { + normalized = normalized.substring(1) + } + + return normalized + } + + /** + * Check if a path matches a pattern using simple glob matching + */ + private matchesPattern(filePath: string, pattern: string): boolean { + try { + // Normalize both for case-insensitive matching + const normalizedFile = filePath.toLowerCase() + const normalizedPattern = pattern.toLowerCase() + + // Handle directory patterns like "confidential/**/*" + if (normalizedPattern.includes("**")) { + const dirPart = normalizedPattern.split("/**")[0] + if (normalizedFile.startsWith(dirPart + "/") || normalizedFile === dirPart) { + return true + } + } + + // Handle wildcard patterns like "*.env", "*token", ".env*" + if (normalizedPattern.includes("*")) { + // Convert glob pattern to regex + const regexPattern = normalizedPattern + .replace(/\./g, "\\.") // Escape dots + .replace(/\*/g, ".*") // Convert * to .* + + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(normalizedFile) + } + + // Exact match + return normalizedFile === normalizedPattern + } catch (error) { + return false + } + } + + /** + * Get standardized error message for security violations (PHASE 1 SECURITY FIX) + * This reduces information leakage by providing consistent, minimal error messages + * that don't reveal system architecture details to AI reconnaissance + */ + getStandardizedErrorMessage(pattern?: string): string { + // Standardized message that doesn't reveal specific security patterns or system details + return "Access denied to confidential file" + } + + /** + * Get standardized error message for command violations (PHASE 1 SECURITY FIX) + * This reduces information leakage for command-based security violations + */ + getStandardizedCommandErrorMessage(): string { + // Standardized message that doesn't reveal specific command patterns or system details + return "Command blocked: Confidential command not allowed" + } + + /** + * Build rule index for enhanced SecurityResult reporting (Phase 2) + * Maps patterns to their rule definitions for better debugging + */ + private buildRuleIndex(): void { + this.ruleIndex.clear() + + // Index confidential file patterns + this.confidentialFiles.forEach((pattern, index) => { + this.ruleIndex.set(pattern, `block.files[${index}]`) + }) + + // Index sensitive file patterns + this.sensitiveFiles.forEach((pattern, index) => { + this.ruleIndex.set(pattern, `ask.files[${index}]`) + }) + + // Index confidential command patterns + this.confidentialCommands.forEach((pattern, index) => { + this.ruleIndex.set(pattern, `block.commands[${index}]`) + }) + + // Index sensitive command patterns + this.sensitiveCommands.forEach((pattern, index) => { + this.ruleIndex.set(pattern, `ask.commands[${index}]`) + }) + + // Index confidential environment variable patterns + this.confidentialEnvVars.forEach((pattern, index) => { + this.ruleIndex.set(pattern, `block.env_vars[${index}]`) + }) + + console.log(`[SecurityGuard] Built rule index with ${this.ruleIndex.size} patterns`) + } + + /** + * Create enhanced SecurityResult with detailed violation information (Phase 2) + */ + private createSecurityResult(params: { + blocked?: boolean + requiresApproval?: boolean + message: string + pattern: string + violationType: "file" | "command" | "env_var" + ruleType: "block" | "ask" + context?: string + }): SecurityResult { + const matchedRule = this.ruleIndex.get(params.pattern) || `unknown_rule(${params.pattern})` + + return { + blocked: params.blocked, + requiresApproval: params.requiresApproval, + message: params.message, + pattern: params.pattern, + violationType: params.violationType, + ruleType: params.ruleType, + matchedRule, + context: params.context, + } + } + + /** + * Create enhanced file SecurityResult (Phase 2) + */ + private createFileSecurityResult(params: { + blocked?: boolean + requiresApproval?: boolean + message: string + pattern: string + ruleType: "block" | "ask" + filePath: string + context?: string + }): SecurityResult { + return this.createSecurityResult({ + blocked: params.blocked, + requiresApproval: params.requiresApproval, + message: params.message, + pattern: params.pattern, + violationType: "file", + ruleType: params.ruleType, + context: params.context || `File access: ${params.filePath}`, + }) + } + + /** + * Create enhanced command SecurityResult (Phase 2) + */ + private createCommandSecurityResult(params: { + blocked?: boolean + requiresApproval?: boolean + message: string + pattern: string + ruleType: "block" | "ask" + command: string + context?: string + }): SecurityResult { + return this.createSecurityResult({ + blocked: params.blocked, + requiresApproval: params.requiresApproval, + message: params.message, + pattern: params.pattern, + violationType: "command", + ruleType: params.ruleType, + context: params.context || `Command execution: ${params.command}`, + }) + } +} diff --git a/src/core/security/__tests__/SecurityGuard.spec.ts b/src/core/security/__tests__/SecurityGuard.spec.ts new file mode 100644 index 0000000000..3583a8327e --- /dev/null +++ b/src/core/security/__tests__/SecurityGuard.spec.ts @@ -0,0 +1,923 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { SecurityGuard, SecurityResult } from "../SecurityGuard" +import { createHierarchicalTestGuard } from "./helpers/test-utils" +import fs from "fs" +import yaml from "yaml" + +// Mock fs and yaml modules +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})) +vi.mock("yaml", () => ({ + default: { + parse: vi.fn(), + }, + parse: vi.fn(), +})) + +describe("SecurityGuard", () => { + let originalConsoleLog: typeof console.log + let originalConsoleError: typeof console.error + let originalConsoleWarn: typeof console.warn + + beforeEach(() => { + // Suppress console output during tests + originalConsoleLog = console.log + originalConsoleError = console.error + originalConsoleWarn = console.warn + console.log = vi.fn() + console.error = vi.fn() + console.warn = vi.fn() + }) + + afterEach(() => { + // Restore console output + console.log = originalConsoleLog + console.error = originalConsoleError + console.warn = originalConsoleWarn + + vi.restoreAllMocks() + }) + + function createTestSecurityGuard(config: any): SecurityGuard { + // Clear any existing mocks + vi.clearAllMocks() + + // Create SecurityGuard with security disabled to avoid file system calls + const guard = new SecurityGuard("/test/cwd", false) + + // Access private properties to inject our test data + const guardAny = guard as any + + // Enable security after construction to avoid loadConfiguration() call + guardAny.isEnabled = true + + // Directly inject test configuration data using new format only + guardAny.confidentialFiles = config.block?.files || [] + guardAny.sensitiveFiles = config.ask?.files || [] + guardAny.confidentialCommands = config.block?.commands || [] + guardAny.sensitiveCommands = config.ask?.commands || [] + guardAny.confidentialEnvVars = config.block?.env_vars || [] + + // Build rule index for enhanced SecurityResult reporting + guardAny.buildRuleIndex() + + return guard + } + + describe("Configuration Loading", () => { + describe("New Format (block/ask)", () => { + it("should load block-only configuration correctly", () => { + const config = { + override_global_config: true, + block: { + files: ["*secret*", "*password*"], + env_vars: ["*_KEY", "*_TOKEN"], + commands: ["env"], + }, + ask: { + files: [], + env_vars: [], + commands: [], + }, + } + + const guard = createTestSecurityGuard(config) + + // Test that blocked files are properly blocked + const result = guard.validateFileAccess("secret.txt") + expect(result?.blocked).toBe(true) + expect(result?.requiresApproval).toBeUndefined() + }) + + it("should load ask-only configuration correctly", () => { + const config = { + override_global_config: true, + block: { + files: [], + env_vars: [], + commands: [], + }, + ask: { + files: [".env*", "*token*"], + env_vars: ["*_API_KEY"], + commands: ["cat"], + }, + } + + const guard = createTestSecurityGuard(config) + + // Test that sensitive files require approval + const result = guard.validateFileAccess(".env") + expect(result?.requiresApproval).toBe(true) + expect(result?.blocked).toBeUndefined() + }) + + it("should load mixed configuration correctly", () => { + const config = { + override_global_config: true, + block: { + files: ["*secret*", "*password*"], + env_vars: ["*_KEY"], + commands: ["env"], + }, + ask: { + files: [".env*", "*token*"], + env_vars: [], + commands: ["blah"], + }, + } + + const guard = createTestSecurityGuard(config) + + // Test blocked file + const blockedResult = guard.validateFileAccess("secret.txt") + expect(blockedResult?.blocked).toBe(true) + + // Test sensitive file + const sensitiveResult = guard.validateFileAccess(".env") + expect(sensitiveResult?.requiresApproval).toBe(true) + }) + }) + }) + + describe("File Access Validation", () => { + let guard: SecurityGuard + + beforeEach(() => { + const config = { + override_global_config: true, + block: { + files: [ + "confidential/**/*", + "*secret*", + "*password*", + "*key*", + "*credential*", + "*cert*", + "*salary*", + "*salaries*", + "*wage*", + "*wages*", + "*payroll*", + "*financial*", + "*finance*", + "*.bak", + "*.backup", + "*.old", + ], + env_vars: ["*_KEY", "*_TOKEN", "*_SECRET"], + commands: ["env"], + }, + ask: { + files: [".env*", "env*", "*token*", "id_rsa", "id_ed25519"], + env_vars: [], + commands: ["blah"], + }, + } + guard = createTestSecurityGuard(config) + }) + + describe("Block Rules", () => { + it("should block files matching confidential patterns", () => { + const testCases = [ + "secret.txt", + "password.dat", + "api-key.json", + "credentials.yml", + "certificate.pem", + "salary-data.csv", + "employee-wages.txt", + "payroll.xlsx", + "financial-report.pdf", + "backup.bak", + "config.backup", + "settings.old", + ] + + testCases.forEach((file) => { + const result = guard.validateFileAccess(file) + expect(result?.blocked).toBe(true) + expect(result?.violationType).toBe("file") + expect(result?.ruleType).toBe("block") + }) + }) + + it("should block confidential directory files", () => { + const testFiles = [ + "confidential/secret.txt", + "confidential/passwords/admin.txt", + "confidential/keys/private.key", + ] + + testFiles.forEach((file) => { + const result = guard.validateFileAccess(file) + expect(result?.blocked).toBe(true) + expect(result?.violationType).toBe("file") + expect(result?.ruleType).toBe("block") + expect(result?.pattern).toBe("confidential/**/*") + }) + }) + }) + + describe("Ask Rules", () => { + it("should require approval for sensitive files", () => { + const testCases = [ + ".env", + ".env.local", + "env.production", + "api-token.txt", + "access-token.json", + "id_rsa", + "id_ed25519", + ] + + testCases.forEach((file) => { + const result = guard.validateFileAccess(file) + expect(result?.requiresApproval).toBe(true) + expect(result?.violationType).toBe("file") + expect(result?.ruleType).toBe("ask") + }) + }) + }) + + describe("No Match Cases", () => { + it("should return null for files that do not match any pattern", () => { + const safeFiles = ["README.md", "package.json", "src/index.ts", "docs/guide.txt", "public/image.png"] + + safeFiles.forEach((file) => { + const result = guard.validateFileAccess(file) + expect(result).toBeNull() + }) + }) + }) + + describe("Case Sensitivity", () => { + it("should handle case-insensitive pattern matching", () => { + const testCases = [ + { file: "SECRET.txt", shouldMatch: true }, + { file: "Secret.txt", shouldMatch: true }, + { file: "secret.TXT", shouldMatch: true }, + { file: "PASSWORD.dat", shouldMatch: true }, + { file: "MyPassword.txt", shouldMatch: true }, + ] + + testCases.forEach(({ file, shouldMatch }) => { + const result = guard.validateFileAccess(file) + if (shouldMatch) { + expect(result?.blocked).toBe(true) + } else { + expect(result).toBeNull() + } + }) + }) + }) + }) + + describe("Command Validation", () => { + let guard: SecurityGuard + + beforeEach(() => { + const config = { + override_global_config: true, + block: { + files: ["*secret*", "*password*", "*key*"], + env_vars: ["*_KEY", "*_TOKEN"], + commands: ["env"], + }, + ask: { + files: [".env*", "*token*"], + env_vars: [], + commands: ["blah"], + }, + } + guard = createTestSecurityGuard(config) + }) + + describe("Phase 1 Fix Verification - Command Names vs File Arguments", () => { + it("should NOT match command names against file patterns", () => { + // These command names should NOT trigger file pattern matches + const commandNames = [ + "xenvx", // Should not match env* file pattern + "secretcommand", // Should not match *secret* file pattern + "passwordgen", // Should not match *password* file pattern + "keylogger", // Should not match *key* file pattern + "tokenizer", // Should not match *token* file pattern + ] + + commandNames.forEach((command) => { + const result = guard.validateCommand(command) + expect(result).toBeNull() // Should pass without security check + }) + }) + + it("should match file arguments against file patterns", () => { + // These commands with file arguments SHOULD trigger pattern matches + const commandsWithFiles = [ + { command: "cat .env", expectedType: "ask" }, + { command: "head secret.txt", expectedType: "block" }, + { command: "tail password.dat", expectedType: "block" }, + { command: "less mytoken.json", expectedType: "ask" }, + ] + + commandsWithFiles.forEach(({ command, expectedType }) => { + const result = guard.validateCommand(command) + expect(result).not.toBeNull() + + if (expectedType === "block") { + expect(result?.blocked).toBe(true) + } else if (expectedType === "ask") { + expect(result?.requiresApproval).toBe(true) + } + }) + }) + }) + + describe("Command Blocking", () => { + it("should block confidential commands", () => { + const result = guard.validateCommand("env") + expect(result?.blocked).toBe(true) + expect(result?.violationType).toBe("command") + expect(result?.ruleType).toBe("block") + }) + + it("should require approval for sensitive commands", () => { + const result = guard.validateCommand("blah") + expect(result?.requiresApproval).toBe(true) + expect(result?.violationType).toBe("command") + expect(result?.ruleType).toBe("ask") + }) + }) + + describe("File-Accessing Commands", () => { + const fileAccessingCommands = ["cat", "head", "tail", "less", "more", "grep", "awk", "sed"] + + fileAccessingCommands.forEach((baseCommand) => { + it(`should validate file arguments for ${baseCommand} command`, () => { + // Test with blocked file + const blockedResult = guard.validateCommand(`${baseCommand} secret.txt`) + expect(blockedResult?.blocked).toBe(true) + + // Test with sensitive file + const sensitiveResult = guard.validateCommand(`${baseCommand} .env`) + expect(sensitiveResult?.requiresApproval).toBe(true) + + // Test with safe file + const safeResult = guard.validateCommand(`${baseCommand} README.md`) + expect(safeResult).toBeNull() + }) + }) + }) + + describe("Scripting Language Commands", () => { + const scriptingLanguages = [ + { lang: "python", flag: "-c" }, + { lang: "python3", flag: "-c" }, + { lang: "ruby", flag: "-e" }, + { lang: "perl", flag: "-e" }, + { lang: "node", flag: "-e" }, + { lang: "nodejs", flag: "-e" }, + ] + + scriptingLanguages.forEach(({ lang, flag }) => { + it(`should validate ${lang} code execution with file access`, () => { + // Test code that accesses blocked files + const blockedCode = `${lang} ${flag} "open('secret.txt').read()"` + const blockedResult = guard.validateCommand(blockedCode) + expect(blockedResult?.blocked).toBe(true) + + // Test code that accesses sensitive files + const sensitiveCode = `${lang} ${flag} "open('.env').read()"` + const sensitiveResult = guard.validateCommand(sensitiveCode) + expect(sensitiveResult?.requiresApproval).toBe(true) + }) + }) + }) + + describe("Command Chaining", () => { + const chainOperators = ["&&", "||", ";", "|"] + + chainOperators.forEach((operator) => { + it(`should validate chained commands with ${operator}`, () => { + const chainedCommand = `ls ${operator} cat secret.txt` + const result = guard.validateCommand(chainedCommand) + expect(result?.blocked).toBe(true) + }) + }) + }) + }) + + describe("Enhanced Interface (Phase 2)", () => { + let guard: SecurityGuard + + beforeEach(() => { + const config = { + override_global_config: true, + block: { + files: ["*secret*", "*wage*"], + commands: ["env"], + }, + ask: { + files: [".env*"], + commands: ["blah"], + }, + } + guard = createTestSecurityGuard(config) + }) + + it("should include all enhanced fields in SecurityResult", () => { + const result = guard.validateFileAccess("secret.txt") + + expect(result).toHaveProperty("violationType") + expect(result).toHaveProperty("ruleType") + expect(result).toHaveProperty("matchedRule") + expect(result).toHaveProperty("context") + + expect(result?.violationType).toBe("file") + expect(result?.ruleType).toBe("block") + expect(result?.matchedRule).toMatch(/^block\.files\[\d+\]$/) + }) + + it("should provide accurate rule index mapping", () => { + // Test specific pattern index mapping + const wageResult = guard.validateFileAccess("employee-wages.txt") + expect(wageResult?.matchedRule).toBe("block.files[1]") // *wage* is at index 1 + + const envResult = guard.validateFileAccess(".env") + expect(envResult?.matchedRule).toBe("ask.files[0]") // .env* is at index 0 + }) + + it("should categorize violation types correctly", () => { + // File violation + const fileResult = guard.validateFileAccess("secret.txt") + expect(fileResult?.violationType).toBe("file") + + // Command violation + const commandResult = guard.validateCommand("env") + expect(commandResult?.violationType).toBe("command") + + // YAML-driven command with file arguments + const fileInCommandResult = guard.validateCommand("cat secret.txt") + expect(fileInCommandResult?.violationType).toBe("file") + }) + }) + + describe("Pattern Matching", () => { + let guard: SecurityGuard + + beforeEach(() => { + const config = { + override_global_config: true, + block: { + files: ["confidential/**/*", "*secret*", "*.backup"], + commands: [], + }, + ask: { + files: ["id_rsa", "id_ed25519"], + commands: [], + }, + } + guard = createTestSecurityGuard(config) + }) + + describe("Wildcard Patterns", () => { + it("should match prefix wildcards correctly", () => { + const result = guard.validateFileAccess("mysecret.txt") + expect(result?.blocked).toBe(true) + expect(result?.pattern).toBe("*secret*") + }) + + it("should match suffix wildcards correctly", () => { + const result = guard.validateFileAccess("data.backup") + expect(result?.blocked).toBe(true) + expect(result?.pattern).toBe("*.backup") + }) + + it("should match middle wildcards correctly", () => { + const result = guard.validateFileAccess("secretdata") + expect(result?.blocked).toBe(true) + expect(result?.pattern).toBe("*secret*") + }) + }) + + describe("Directory Patterns", () => { + it("should match directory patterns with **/*", () => { + const testPaths = [ + "confidential/file.txt", + "confidential/subdir/file.txt", + "confidential/deep/nested/path/file.txt", + ] + + testPaths.forEach((path) => { + const result = guard.validateFileAccess(path) + expect(result?.blocked).toBe(true) + expect(result?.pattern).toBe("confidential/**/*") + }) + }) + }) + + describe("Exact Matches", () => { + it("should match exact filenames", () => { + const exactFiles = ["id_rsa", "id_ed25519"] + + exactFiles.forEach((file) => { + const result = guard.validateFileAccess(file) + expect(result?.requiresApproval).toBe(true) + expect(result?.pattern).toBe(file) + }) + }) + }) + }) + + describe("Performance", () => { + let guard: SecurityGuard + + beforeEach(() => { + const config = { + override_global_config: true, + block: { + files: ["confidential/**/*", "*secret*", "*.backup"], + commands: [], + }, + ask: { + files: ["id_rsa", "id_ed25519"], + commands: [], + }, + } + guard = createTestSecurityGuard(config) + }) + + it("should validate file access within reasonable time", () => { + const start = performance.now() + guard.validateFileAccess("secret.txt") + const duration = performance.now() - start + + expect(duration).toBeLessThan(10) // Should complete within 10ms + }) + + it("should validate command within reasonable time", () => { + const start = performance.now() + guard.validateCommand("cat secret.txt") + const duration = performance.now() - start + + expect(duration).toBeLessThan(10) // Should complete within 10ms + }) + + it("should handle multiple validations efficiently", () => { + const testFiles = Array.from({ length: 100 }, (_, i) => `test-file-${i}.txt`) + + const start = performance.now() + testFiles.forEach((file) => guard.validateFileAccess(file)) + const duration = performance.now() - start + + expect(duration).toBeLessThan(100) // Should complete 100 validations within 100ms + }) + }) + + describe("Error Handling", () => { + it("should handle empty file paths gracefully", () => { + const config = { + override_global_config: true, + block: { files: ["*secret*"] }, + ask: { files: [".env*"] }, + } + const guard = createTestSecurityGuard(config) + + expect(() => guard.validateFileAccess("")).not.toThrow() + expect(guard.validateFileAccess("")).toBeNull() + }) + + it("should handle empty commands gracefully", () => { + const config = { + override_global_config: true, + block: { commands: ["env"] }, + ask: { commands: ["blah"] }, + } + const guard = createTestSecurityGuard(config) + + expect(() => guard.validateCommand("")).not.toThrow() + expect(guard.validateCommand("")).toBeNull() + }) + }) + + describe("Integration Scenarios", () => { + let guard: SecurityGuard + + beforeEach(() => { + const config = { + override_global_config: true, + block: { + files: ["*secret*"], + commands: ["env"], + }, + ask: { + files: [".env*"], + commands: ["blah"], + }, + } + guard = createTestSecurityGuard(config) + }) + + it("should handle real-world command scenarios", () => { + const realWorldCommands = [ + { command: "git status", shouldPass: true }, + { command: "npm install", shouldPass: true }, + { command: "docker run -v .env:/app/.env myapp", shouldPass: false }, + { command: 'find . -name "*.secret" -exec cat {} \\;', shouldPass: false }, + ] + + realWorldCommands.forEach(({ command, shouldPass }) => { + const result = guard.validateCommand(command) + if (shouldPass) { + expect(result).toBeNull() + } else { + expect(result).not.toBeNull() + } + }) + }) + + it("should prioritize file blocking over command sensitivity", () => { + // Command that would normally require approval, but accesses blocked file + const result = guard.validateCommand("blah secret.txt") + + // Should be blocked due to file, not just require approval due to command + expect(result?.blocked).toBe(true) + expect(result?.violationType).toBe("file") + expect(result?.pattern).toBe("*secret*") + }) + }) + + describe("Hierarchical Configuration Merging", () => { + describe("BLOCK Always Wins Principle", () => { + it("should prioritize BLOCK over ASK regardless of source", () => { + const global = { block: { files: [".env"] } } + const project = { ask: { files: [".env"] } } + + const guard = createHierarchicalTestGuard(global, project) + + const result = guard.validateFileAccess(".env") + expect(result?.blocked).toBe(true) + expect(result?.requiresApproval).toBeUndefined() + expect(result?.ruleType).toBe("block") + }) + + it("should prioritize project BLOCK over global ASK", () => { + const global = { ask: { files: ["*.key"] } } + const project = { block: { files: ["*.key"] } } + + const guard = createHierarchicalTestGuard(global, project) + + const result = guard.validateFileAccess("private.key") + expect(result?.blocked).toBe(true) + expect(result?.requiresApproval).toBeUndefined() + expect(result?.ruleType).toBe("block") + }) + + it("should prioritize global BLOCK over project ASK", () => { + const global = { block: { files: ["*secret*"] } } + const project = { ask: { files: ["*secret*"] } } + + const guard = createHierarchicalTestGuard(global, project) + + const result = guard.validateFileAccess("secret.txt") + expect(result?.blocked).toBe(true) + expect(result?.requiresApproval).toBeUndefined() + expect(result?.ruleType).toBe("block") + }) + }) + + describe("Configuration Combining", () => { + it("should combine BLOCK rules from both sources", () => { + const global = { block: { files: ["*secret*"] } } + const project = { block: { files: ["*.key"] } } + + const guard = createHierarchicalTestGuard(global, project) + + // Both patterns should be blocked + expect(guard.validateFileAccess("secret.txt")?.blocked).toBe(true) + expect(guard.validateFileAccess("private.key")?.blocked).toBe(true) + }) + + it("should combine ASK rules from both sources when no conflicts", () => { + const global = { ask: { files: [".env*"] } } + const project = { ask: { files: ["*.json"] } } + + const guard = createHierarchicalTestGuard(global, project) + + // Both patterns should require approval + expect(guard.validateFileAccess(".env")?.requiresApproval).toBe(true) + expect(guard.validateFileAccess("config.json")?.requiresApproval).toBe(true) + }) + + it("should combine commands from both sources", () => { + const global = { + block: { commands: ["env"] }, + ask: { commands: ["cat"] }, + } + const project = { + block: { commands: ["docker"] }, + ask: { commands: ["npm"] }, + } + + const guard = createHierarchicalTestGuard(global, project) + + // All commands should be handled according to their rules + expect(guard.validateCommand("env")?.blocked).toBe(true) + expect(guard.validateCommand("docker")?.blocked).toBe(true) + expect(guard.validateCommand("cat")?.requiresApproval).toBe(true) + expect(guard.validateCommand("npm")?.requiresApproval).toBe(true) + }) + }) + + describe("Missing Configuration Handling", () => { + it("should handle missing global config gracefully", () => { + const global = {} // Empty global config + const project = { block: { files: ["*.key"] } } + + const guard = createHierarchicalTestGuard(global, project) + + expect(guard.validateFileAccess("private.key")?.blocked).toBe(true) + }) + + it("should handle missing project config gracefully", () => { + const global = { block: { files: ["*secret*"] } } + const project = {} // Empty project config + + const guard = createHierarchicalTestGuard(global, project) + + expect(guard.validateFileAccess("secret.txt")?.blocked).toBe(true) + }) + + it("should handle both configs missing gracefully", () => { + const global = {} // Empty global config + const project = {} // Empty project config + + const guard = createHierarchicalTestGuard(global, project) + + // Should not block anything when no rules are defined + expect(guard.validateFileAccess("any-file.txt")).toBeNull() + expect(guard.validateCommand("any-command")).toBeNull() + }) + }) + + describe("Three-Tier Configuration System", () => { + it("should merge global → project → custom configurations correctly", () => { + const global = { + block: { files: ["*secret*"], commands: ["env"] }, + ask: { files: [".env*"], commands: ["cat"] }, + } + + const project = { + block: { files: ["*.key"], commands: ["docker"] }, + ask: { files: ["*.json"], commands: ["npm"] }, + } + + const custom = { + block: { files: ["*password*"], commands: ["kubectl"] }, + ask: { files: ["*.yaml"], commands: ["helm"] }, + } + + const guard = createHierarchicalTestGuard(global, project, custom) + + // All three levels should contribute to BLOCK rules + expect(guard.validateFileAccess("secret.txt")?.blocked).toBe(true) // global + expect(guard.validateFileAccess("private.key")?.blocked).toBe(true) // project + expect(guard.validateFileAccess("password.dat")?.blocked).toBe(true) // custom + expect(guard.validateCommand("env")?.blocked).toBe(true) // global + expect(guard.validateCommand("docker")?.blocked).toBe(true) // project + expect(guard.validateCommand("kubectl")?.blocked).toBe(true) // custom + + // All three levels should contribute to ASK rules (when not blocked) + expect(guard.validateFileAccess(".env")?.requiresApproval).toBe(true) // global + expect(guard.validateFileAccess("config.json")?.requiresApproval).toBe(true) // project + expect(guard.validateFileAccess("settings.yaml")?.requiresApproval).toBe(true) // custom + expect(guard.validateCommand("cat")?.requiresApproval).toBe(true) // global + expect(guard.validateCommand("npm")?.requiresApproval).toBe(true) // project + expect(guard.validateCommand("helm")?.requiresApproval).toBe(true) // custom + }) + + it("should prioritize custom BLOCK over global/project ASK", () => { + const global = { ask: { files: ["*.key"] } } + const project = { ask: { files: ["*.key"] } } + const custom = { block: { files: ["*.key"] } } + + const guard = createHierarchicalTestGuard(global, project, custom) + + const result = guard.validateFileAccess("private.key") + expect(result?.blocked).toBe(true) + expect(result?.requiresApproval).toBeUndefined() + expect(result?.ruleType).toBe("block") + }) + + it("should handle custom config with empty values", () => { + const global = { block: { files: ["*secret*"] } } + const project = { ask: { files: [".env*"] } } + const custom = { block: { files: [] }, ask: { files: [] } } + + const guard = createHierarchicalTestGuard(global, project, custom) + + // Global and project rules should still work + expect(guard.validateFileAccess("secret.txt")?.blocked).toBe(true) + expect(guard.validateFileAccess(".env")?.requiresApproval).toBe(true) + }) + + it("should handle missing custom config gracefully", () => { + const global = { block: { files: ["*secret*"] } } + const project = { ask: { files: [".env*"] } } + // No custom config provided (undefined) + + const guard = createHierarchicalTestGuard(global, project) + + // Should work exactly like before + expect(guard.validateFileAccess("secret.txt")?.blocked).toBe(true) + expect(guard.validateFileAccess(".env")?.requiresApproval).toBe(true) + }) + }) + + describe("Complex Hierarchical Scenarios", () => { + it("should handle complex real-world configuration merging", () => { + const global = { + block: { + files: ["confidential/**/*", "*secret*", "*password*"], + env_vars: ["*_KEY", "*_TOKEN"], + commands: ["env"], + }, + ask: { + files: [".env*", "*token*"], + commands: ["cat"], + }, + } + + const project = { + block: { + files: ["*.key", "*credential*"], // Additional project restrictions + commands: ["docker"], // Project-specific command restriction + }, + ask: { + files: ["*.json", "*.yaml"], // Project wants to review config files + commands: ["npm", "yarn"], // Project wants approval for package managers + }, + } + + const guard = createHierarchicalTestGuard(global, project) + + // Global BLOCK rules should work + expect(guard.validateFileAccess("confidential/secret.txt")?.blocked).toBe(true) + expect(guard.validateFileAccess("password.txt")?.blocked).toBe(true) + expect(guard.validateCommand("env")?.blocked).toBe(true) + + // Project BLOCK rules should work + expect(guard.validateFileAccess("private.key")?.blocked).toBe(true) + expect(guard.validateFileAccess("credentials.json")?.blocked).toBe(true) + expect(guard.validateCommand("docker")?.blocked).toBe(true) + + // Global ASK rules should work (when not overridden by BLOCK) + expect(guard.validateFileAccess(".env")?.requiresApproval).toBe(true) + expect(guard.validateFileAccess("api-token.txt")?.requiresApproval).toBe(true) + expect(guard.validateCommand("cat")?.requiresApproval).toBe(true) + + // Project ASK rules should work (when not overridden by BLOCK) + expect(guard.validateFileAccess("config.yaml")?.requiresApproval).toBe(true) + expect(guard.validateCommand("npm")?.requiresApproval).toBe(true) + + // BLOCK should override ASK for same patterns + // Note: credentials.json is blocked by project BLOCK, not asked by project ASK + expect(guard.validateFileAccess("credentials.json")?.blocked).toBe(true) + expect(guard.validateFileAccess("credentials.json")?.requiresApproval).toBeUndefined() + }) + + it("should remove ASK patterns that are also in BLOCK", () => { + const global = { + block: { files: [".env"] }, + ask: { files: [".env", "*.json"] }, // .env appears in both + } + const project = { + block: { files: ["*.key"] }, + ask: { files: ["*.key", "*.yaml"] }, // *.key appears in both + } + + const guard = createHierarchicalTestGuard(global, project) + + // Files that appear in BLOCK should be blocked, not asked + expect(guard.validateFileAccess(".env")?.blocked).toBe(true) + expect(guard.validateFileAccess(".env")?.requiresApproval).toBeUndefined() + + expect(guard.validateFileAccess("private.key")?.blocked).toBe(true) + expect(guard.validateFileAccess("private.key")?.requiresApproval).toBeUndefined() + + // Files that only appear in ASK should require approval + expect(guard.validateFileAccess("config.json")?.requiresApproval).toBe(true) + expect(guard.validateFileAccess("config.yaml")?.requiresApproval).toBe(true) + }) + }) + }) +}) diff --git a/src/core/security/__tests__/fixtures/security-middleware-test-configs/ask-only.yaml b/src/core/security/__tests__/fixtures/security-middleware-test-configs/ask-only.yaml new file mode 100644 index 0000000000..c466a81455 --- /dev/null +++ b/src/core/security/__tests__/fixtures/security-middleware-test-configs/ask-only.yaml @@ -0,0 +1,27 @@ +# Test configuration with only ask rules +override_global_config: true + +block: + files: [] + env_vars: [] + commands: [] + +ask: + files: + - .env* + - env* + - '*token*' + - id_rsa + - id_ed25519 + - '*.pem' + - '*.p12' + - '*.pfx' + env_vars: + - '*_API_KEY' + - '*_AUTH_TOKEN' + commands: + - cat + - head + - tail + - less + - more diff --git a/src/core/security/__tests__/fixtures/security-middleware-test-configs/block-only.yaml b/src/core/security/__tests__/fixtures/security-middleware-test-configs/block-only.yaml new file mode 100644 index 0000000000..b2647cddf1 --- /dev/null +++ b/src/core/security/__tests__/fixtures/security-middleware-test-configs/block-only.yaml @@ -0,0 +1,39 @@ +# Test configuration with only block rules +override_global_config: true + +block: + files: + - confidential/**/* + - '*secret*' + - '*password*' + - '*key*' + - '*credential*' + - '*cert*' + - '*salary*' + - '*salaries*' + - '*wage*' + - '*wages*' + - '*payroll*' + - '*financial*' + - '*finance*' + - '*.bak' + - '*.backup' + - '*.old' + env_vars: + - '*_KEY' + - '*_TOKEN' + - '*_SECRET' + - '*_PASSWORD' + - '*_PASSPHRASE' + - '*_CREDENTIALS' + - '*_CERT' + - '*DATABASE_URL' + - '*DB_URI' + commands: + - env + - printenv + +ask: + files: [] + env_vars: [] + commands: [] diff --git a/src/core/security/__tests__/fixtures/security-middleware-test-configs/edge-cases.yaml b/src/core/security/__tests__/fixtures/security-middleware-test-configs/edge-cases.yaml new file mode 100644 index 0000000000..8e05cd764e --- /dev/null +++ b/src/core/security/__tests__/fixtures/security-middleware-test-configs/edge-cases.yaml @@ -0,0 +1,38 @@ +# Test configuration for edge cases and error handling +override_global_config: true + +block: + files: + # Complex patterns for testing + - '**/*.secret' + - 'config/**/private/*' + - '*[Pp]assword*' + - '*.{key,pem,p12}' + # Special characters + - '*$ecret*' + - '*@private*' + - '*#confidential*' + # Case sensitivity tests + - '*SECRET*' + - '*Secret*' + - '*sEcReT*' + env_vars: + - 'API_*_KEY' + - '*_SECRET_*' + commands: + - 'env*' + - '*printenv*' + +ask: + files: + # Empty patterns (should be ignored) + - '' + # Whitespace patterns + - ' .env ' + # Unicode patterns + - '*tøken*' + env_vars: [] + commands: + # Duplicate commands (should be handled) + - cat + - cat diff --git a/src/core/security/__tests__/fixtures/security-middleware-test-configs/mixed-rules.yaml b/src/core/security/__tests__/fixtures/security-middleware-test-configs/mixed-rules.yaml new file mode 100644 index 0000000000..86ce158f32 --- /dev/null +++ b/src/core/security/__tests__/fixtures/security-middleware-test-configs/mixed-rules.yaml @@ -0,0 +1,44 @@ +# Test configuration with both block and ask rules (current production config) +override_global_config: true + +block: + files: + - confidential/**/* + - '*secret*' + - '*password*' + - '*key*' + - '*credential*' + - '*cert*' + - '*salary*' + - '*salaries*' + - '*wage*' + - '*wages*' + - '*payroll*' + - '*financial*' + - '*finance*' + - '*.bak' + - '*.backup' + - '*.old' + env_vars: + - '*_KEY' + - '*_TOKEN' + - '*_SECRET' + - '*_PASSWORD' + - '*_PASSPHRASE' + - '*_CREDENTIALS' + - '*_CERT' + - '*DATABASE_URL' + - '*DB_URI' + commands: + - env + +ask: + files: + - .env* + - env* + - '*token*' + - id_rsa + - id_ed25519 + env_vars: [] + commands: + - blah diff --git a/src/core/security/__tests__/fixtures/test-configs/ask-only.yaml b/src/core/security/__tests__/fixtures/test-configs/ask-only.yaml new file mode 100644 index 0000000000..c466a81455 --- /dev/null +++ b/src/core/security/__tests__/fixtures/test-configs/ask-only.yaml @@ -0,0 +1,27 @@ +# Test configuration with only ask rules +override_global_config: true + +block: + files: [] + env_vars: [] + commands: [] + +ask: + files: + - .env* + - env* + - '*token*' + - id_rsa + - id_ed25519 + - '*.pem' + - '*.p12' + - '*.pfx' + env_vars: + - '*_API_KEY' + - '*_AUTH_TOKEN' + commands: + - cat + - head + - tail + - less + - more diff --git a/src/core/security/__tests__/fixtures/test-configs/block-only.yaml b/src/core/security/__tests__/fixtures/test-configs/block-only.yaml new file mode 100644 index 0000000000..b2647cddf1 --- /dev/null +++ b/src/core/security/__tests__/fixtures/test-configs/block-only.yaml @@ -0,0 +1,39 @@ +# Test configuration with only block rules +override_global_config: true + +block: + files: + - confidential/**/* + - '*secret*' + - '*password*' + - '*key*' + - '*credential*' + - '*cert*' + - '*salary*' + - '*salaries*' + - '*wage*' + - '*wages*' + - '*payroll*' + - '*financial*' + - '*finance*' + - '*.bak' + - '*.backup' + - '*.old' + env_vars: + - '*_KEY' + - '*_TOKEN' + - '*_SECRET' + - '*_PASSWORD' + - '*_PASSPHRASE' + - '*_CREDENTIALS' + - '*_CERT' + - '*DATABASE_URL' + - '*DB_URI' + commands: + - env + - printenv + +ask: + files: [] + env_vars: [] + commands: [] diff --git a/src/core/security/__tests__/fixtures/test-configs/edge-cases.yaml b/src/core/security/__tests__/fixtures/test-configs/edge-cases.yaml new file mode 100644 index 0000000000..8e05cd764e --- /dev/null +++ b/src/core/security/__tests__/fixtures/test-configs/edge-cases.yaml @@ -0,0 +1,38 @@ +# Test configuration for edge cases and error handling +override_global_config: true + +block: + files: + # Complex patterns for testing + - '**/*.secret' + - 'config/**/private/*' + - '*[Pp]assword*' + - '*.{key,pem,p12}' + # Special characters + - '*$ecret*' + - '*@private*' + - '*#confidential*' + # Case sensitivity tests + - '*SECRET*' + - '*Secret*' + - '*sEcReT*' + env_vars: + - 'API_*_KEY' + - '*_SECRET_*' + commands: + - 'env*' + - '*printenv*' + +ask: + files: + # Empty patterns (should be ignored) + - '' + # Whitespace patterns + - ' .env ' + # Unicode patterns + - '*tøken*' + env_vars: [] + commands: + # Duplicate commands (should be handled) + - cat + - cat diff --git a/src/core/security/__tests__/fixtures/test-configs/mixed-rules.yaml b/src/core/security/__tests__/fixtures/test-configs/mixed-rules.yaml new file mode 100644 index 0000000000..86ce158f32 --- /dev/null +++ b/src/core/security/__tests__/fixtures/test-configs/mixed-rules.yaml @@ -0,0 +1,44 @@ +# Test configuration with both block and ask rules (current production config) +override_global_config: true + +block: + files: + - confidential/**/* + - '*secret*' + - '*password*' + - '*key*' + - '*credential*' + - '*cert*' + - '*salary*' + - '*salaries*' + - '*wage*' + - '*wages*' + - '*payroll*' + - '*financial*' + - '*finance*' + - '*.bak' + - '*.backup' + - '*.old' + env_vars: + - '*_KEY' + - '*_TOKEN' + - '*_SECRET' + - '*_PASSWORD' + - '*_PASSPHRASE' + - '*_CREDENTIALS' + - '*_CERT' + - '*DATABASE_URL' + - '*DB_URI' + commands: + - env + +ask: + files: + - .env* + - env* + - '*token*' + - id_rsa + - id_ed25519 + env_vars: [] + commands: + - blah diff --git a/src/core/security/__tests__/helpers/test-utils.ts b/src/core/security/__tests__/helpers/test-utils.ts new file mode 100644 index 0000000000..9aa66bad22 --- /dev/null +++ b/src/core/security/__tests__/helpers/test-utils.ts @@ -0,0 +1,72 @@ +import { vi } from "vitest" +import { SecurityGuard } from "../../SecurityGuard" + +/** + * Create SecurityGuard instance with hierarchical configuration for testing + * Used by tests in SecurityGuard.spec.ts for complex configuration merging scenarios + */ +export function createHierarchicalTestGuard(globalConfig: any, projectConfig: any, customConfig?: any): SecurityGuard { + // Clear any existing mocks + vi.clearAllMocks() + + // Create a SecurityGuard instance with security disabled to avoid file system calls + const guard = new SecurityGuard("/test/cwd", false) + + // Access private properties to inject our test data + const guardAny = guard as any + + // Enable security after construction to avoid loadConfiguration() call + guardAny.isEnabled = true + + // Merge configurations with BLOCK-always-wins logic using new format only + // Order: global → project → custom (each level can override previous) + const allBlockFiles = [ + ...(globalConfig.block?.files || []), + ...(projectConfig.block?.files || []), + ...(customConfig?.block?.files || []), + ] + + const allBlockCommands = [ + ...(globalConfig.block?.commands || []), + ...(projectConfig.block?.commands || []), + ...(customConfig?.block?.commands || []), + ] + + const allBlockEnvVars = [ + ...(globalConfig.block?.env_vars || []), + ...(projectConfig.block?.env_vars || []), + ...(customConfig?.block?.env_vars || []), + ] + + const allAskFiles = [ + ...(globalConfig.ask?.files || []), + ...(projectConfig.ask?.files || []), + ...(customConfig?.ask?.files || []), + ] + + const allAskCommands = [ + ...(globalConfig.ask?.commands || []), + ...(projectConfig.ask?.commands || []), + ...(customConfig?.ask?.commands || []), + ] + + // Remove duplicates + guardAny.confidentialFiles = [...new Set(allBlockFiles)] + guardAny.confidentialCommands = [...new Set(allBlockCommands)] + guardAny.confidentialEnvVars = [...new Set(allBlockEnvVars)] + guardAny.sensitiveFiles = [...new Set(allAskFiles)] + guardAny.sensitiveCommands = [...new Set(allAskCommands)] + + // CRITICAL: Remove any ASK patterns that are also in BLOCK (BLOCK always wins) + guardAny.sensitiveFiles = guardAny.sensitiveFiles.filter( + (pattern: string) => !guardAny.confidentialFiles.includes(pattern), + ) + guardAny.sensitiveCommands = guardAny.sensitiveCommands.filter( + (pattern: string) => !guardAny.confidentialCommands.includes(pattern), + ) + + // Build rule index for enhanced SecurityResult reporting + guardAny.buildRuleIndex() + + return guard +} diff --git a/src/core/security/index.ts b/src/core/security/index.ts new file mode 100644 index 0000000000..1f98c01d43 --- /dev/null +++ b/src/core/security/index.ts @@ -0,0 +1 @@ +export { SecurityGuard, type SecurityResult } from "./SecurityGuard" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ccd24b7d71..b679b3889d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -71,6 +71,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" +import { SecurityGuard } from "../security" import { type AssistantMessageContent, parseAssistantMessage, presentAssistantMessage } from "../assistant-message" import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" @@ -164,6 +165,7 @@ export class Task extends EventEmitter { toolRepetitionDetector: ToolRepetitionDetector rooIgnoreController?: RooIgnoreController rooProtectedController?: RooProtectedController + securityGuard: SecurityGuard fileContextTracker: FileContextTracker urlContentFetcher: UrlContentFetcher terminalProcess?: RooTerminalProcess @@ -244,6 +246,9 @@ export class Task extends EventEmitter { this.rooIgnoreController = new RooIgnoreController(this.cwd) this.rooProtectedController = new RooProtectedController(this.cwd) + + // Initialize SecurityGuard with disabled default (proper setup happens in checkAndSetupCustomConfigPath) + this.securityGuard = new SecurityGuard(this.cwd, false) this.fileContextTracker = new FileContextTracker(provider, this.taskId) this.rooIgnoreController.initialize().catch((error) => { @@ -297,9 +302,22 @@ export class Task extends EventEmitter { if (startTask) { if (task || images) { - this.startTask(task, images) + this.startTask(task, images).catch((error) => { + // Handle promise rejection silently if task is aborted + if (!this.abort && !this.abandoned) { + console.error(`Unhandled error in startTask for task ${this.taskId}.${this.instanceId}:`, error) + } + }) } else if (historyItem) { - this.resumeTaskFromHistory() + this.resumeTaskFromHistory().catch((error) => { + // Handle promise rejection silently if task is aborted + if (!this.abort && !this.abandoned) { + console.error( + `Unhandled error in resumeTaskFromHistory for task ${this.taskId}.${this.instanceId}:`, + error, + ) + } + }) } else { throw new Error("Either historyItem or task/images must be provided") } @@ -734,6 +752,11 @@ export class Task extends EventEmitter { // Start / Abort / Resume private async startTask(task?: string, images?: string[]): Promise { + // Check if task was aborted before we start + if (this.abort) { + throw new Error(`[RooCode#startTask] task ${this.taskId}.${this.instanceId} aborted`) + } + // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. // If the extension process were killed, then on restart the @@ -744,6 +767,21 @@ export class Task extends EventEmitter { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#startTask] task ${this.taskId}.${this.instanceId} aborted`) + } + + // Check for custom config path setup after provider state is available + console.log("[Task] About to call checkAndSetupCustomConfigPath()") + await this.checkAndSetupCustomConfigPath() + console.log("[Task] Finished checkAndSetupCustomConfigPath()") + + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#startTask] task ${this.taskId}.${this.instanceId} aborted`) + } + await this.say("text", task, images) this.isInitialized = true @@ -785,8 +823,18 @@ export class Task extends EventEmitter { } private async resumeTaskFromHistory() { + // Check if task was aborted before we start + if (this.abort) { + throw new Error(`[RooCode#resumeTaskFromHistory] task ${this.taskId}.${this.instanceId} aborted`) + } + const modifiedClineMessages = await this.getSavedClineMessages() + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#resumeTaskFromHistory] task ${this.taskId}.${this.instanceId} aborted`) + } + // Remove any resume messages that may have been added before const lastRelevantMessageIndex = findLastIndex( modifiedClineMessages, @@ -812,8 +860,19 @@ export class Task extends EventEmitter { } await this.overwriteClineMessages(modifiedClineMessages) + + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#resumeTaskFromHistory] task ${this.taskId}.${this.instanceId} aborted`) + } + this.clineMessages = await this.getSavedClineMessages() + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#resumeTaskFromHistory] task ${this.taskId}.${this.instanceId} aborted`) + } + // Now present the cline messages to the user and ask if they want to // resume (NOTE: we ran into a bug before where the // apiConversationHistory wouldn't be initialized when opening a old @@ -822,6 +881,11 @@ export class Task extends EventEmitter { // the task first. this.apiConversationHistory = await this.getSavedApiConversationHistory() + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#resumeTaskFromHistory] task ${this.taskId}.${this.instanceId} aborted`) + } + const lastClineMessage = this.clineMessages .slice() .reverse() @@ -836,6 +900,14 @@ export class Task extends EventEmitter { this.isInitialized = true + // Check for custom config path setup after provider state is available + await this.checkAndSetupCustomConfigPath() + + // Check if task was aborted after async operation + if (this.abort) { + throw new Error(`[RooCode#resumeTaskFromHistory] task ${this.taskId}.${this.instanceId} aborted`) + } + const { response, text, images } = await this.ask(askType) // calls poststatetowebview let responseText: string | undefined let responseImages: string[] | undefined @@ -1939,6 +2011,47 @@ export class Task extends EventEmitter { } } + // Custom Config Path Setup + + private async checkAndSetupCustomConfigPath(): Promise { + try { + console.log("[SecurityGuard] checkAndSetupCustomConfigPath() starting") + + const provider = this.providerRef.deref() + if (!provider) { + console.log("[SecurityGuard] Provider not available for custom config path setup") + return + } + + const state = await provider.getState() + const isSecurityEnabled = + experiments.isEnabled(state.experiments ?? {}, EXPERIMENT_IDS.SECURITY_MIDDLEWARE) ?? false + + console.log(`[SecurityGuard] Security middleware enabled: ${isSecurityEnabled}`) + + // Get the custom path from the extension state (set via UI) + const customPath = (state as any).securityCustomConfigPath + + console.log(`[SecurityGuard] Retrieved custom config path from state: "${customPath}"`) + console.log(`[SecurityGuard] Custom path type: ${typeof customPath}`) + console.log(`[SecurityGuard] Custom path length: ${customPath ? customPath.length : "undefined"}`) + + if (customPath && customPath.trim()) { + // We have a custom path from the UI, update SecurityGuard with it + console.log(`[SecurityGuard] Creating SecurityGuard with custom config: "${customPath.trim()}"`) + this.securityGuard = new SecurityGuard(this.cwd, isSecurityEnabled, customPath.trim()) + } else { + // Just use the regular SecurityGuard without custom path + console.log("[SecurityGuard] Creating SecurityGuard without custom config") + this.securityGuard = new SecurityGuard(this.cwd, isSecurityEnabled) + } + + console.log("[SecurityGuard] checkAndSetupCustomConfigPath() completed") + } catch (error) { + console.error("[SecurityGuard] Error in checkAndSetupCustomConfigPath:", error) + } + } + // Getters public get cwd() { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 9aa5a8d7a8..d02c86aadb 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -874,10 +874,11 @@ describe("Cline", () => { describe("processUserContentMentions", () => { it("should process mentions in task and feedback tags", async () => { - const [cline, task] = Task.create({ + const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", + startTask: false, }) const userContent = [ @@ -944,8 +945,8 @@ describe("Cline", () => { "Regular tool result with 'path' (see below for file content)", ) - await cline.abortTask(true) - await task.catch(() => {}) + // Clean up without starting the task + cline.dispose() }) }) }) diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts index b9e0af3a8a..4fd062144b 100644 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts @@ -6,6 +6,7 @@ import * as fs from "fs/promises" import { executeCommand, executeCommandTool, ExecuteCommandOptions } from "../executeCommandTool" import { Task } from "../../task/Task" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +import { SecurityGuard } from "../../security/SecurityGuard" // Mock dependencies vitest.mock("vscode", () => ({ @@ -248,6 +249,8 @@ describe("Command Execution Timeout Integration", () => { rooIgnoreController: { validateCommand: vitest.fn().mockReturnValue(null), }, + + securityGuard: new SecurityGuard("/test/directory", false), lastMessageTs: Date.now(), ask: vitest.fn(), didRejectTool: false, diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index ad4bb0590f..db7915a759 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -75,6 +75,24 @@ export async function applyDiffToolLegacy( return } + // Check security validation + const securityCheck = cline.securityGuard.validateFileAccess(relPath) + if (securityCheck?.blocked) { + // Confidential files - complete block + await cline.say("error", securityCheck.message) + pushToolResult(formatResponse.toolError("Access denied to confidential file")) + return + } else if (securityCheck?.requiresApproval) { + // Sensitive files - user approval required + const { response } = await cline.ask("command", securityCheck.message) + if (response !== "yesButtonClicked") { + await cline.say("error", "Access denied to sensitive file") + pushToolResult(formatResponse.toolError("Access denied to sensitive file")) + return + } + // User approved sensitive file access - continue with operation + } + const absolutePath = path.resolve(cline.cwd, relPath) const fileExists = await fileExistsAtPath(absolutePath) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 81dc1993b2..4163f9486c 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -51,13 +51,46 @@ export async function executeCommandTool( return } + // Check security validation + const securityViolation = cline.securityGuard.validateCommand(command) + if (securityViolation?.blocked) { + // Confidential commands - complete block + await cline.say( + "error", + `🚫 **ACCESS DENIED**: Cannot execute confidential command.\n\n⚡ **Command**: \`${command}\`\n🛡️ **Security Pattern**: \`${securityViolation.pattern}\`\n- Matched Rule: ${securityViolation.matchedRule}`, + ) + pushToolResult(formatResponse.toolError("Command blocked: Confidential command not allowed")) + return + } else if (securityViolation?.requiresApproval) { + // Commands accessing sensitive files - user approval required + // First, inform the user in the chat about the sensitive command + await cline.say( + "text", + `🛡️ **PERMISSION REQUIRED**: Sensitive command requires approval for AI execution.\n\n⚡ **Command**: \`${command}\`\n🛡️ **Security Pattern**: \`${securityViolation.pattern}\`\n- Matched Rule: ${securityViolation.matchedRule}`, + ) + + // Then ask for user permission using askApproval function + const didApprove = await askApproval("command", command, undefined, true) + + if (!didApprove) { + // User denied access + await cline.say("error", "Command blocked: Access denied to sensitive command") + pushToolResult(formatResponse.toolError("Command blocked: Access denied to sensitive command")) + return + } + } + cline.consecutiveMistakeCount = 0 command = unescapeHtmlEntities(command) // Unescape HTML entities. - const didApprove = await askApproval("command", command) - if (!didApprove) { - return + // Only ask for normal approval if security didn't already handle it + if (!securityViolation?.requiresApproval) { + const didApprove = await askApproval("command", command) + + if (!didApprove) { + return + } } const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString() diff --git a/src/core/tools/insertContentTool.ts b/src/core/tools/insertContentTool.ts index 2b31224400..7ca46e270c 100644 --- a/src/core/tools/insertContentTool.ts +++ b/src/core/tools/insertContentTool.ts @@ -67,6 +67,24 @@ export async function insertContentTool( return } + // Check security validation + const securityCheck = cline.securityGuard?.validateFileAccess?.(relPath) + if (securityCheck?.blocked) { + // Confidential files - complete block + await cline.say("error", securityCheck.message) + pushToolResult(formatResponse.toolError("Access denied to confidential file")) + return + } else if (securityCheck?.requiresApproval) { + // Sensitive files - user approval required + const { response } = await cline.ask("command", securityCheck.message) + if (response !== "yesButtonClicked") { + await cline.say("error", "Access denied to sensitive file") + pushToolResult(formatResponse.toolError("Access denied to sensitive file")) + return + } + // User approved sensitive file access - continue with operation + } + // Check if file is write-protected const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/multiApplyDiffTool.ts index 4ddef4880b..8d6aef8315 100644 --- a/src/core/tools/multiApplyDiffTool.ts +++ b/src/core/tools/multiApplyDiffTool.ts @@ -242,6 +242,30 @@ Original error: ${errorMessage}` continue } + // Check security validation + const securityCheck = cline.securityGuard.validateFileAccess(relPath) + if (securityCheck?.blocked) { + // Confidential files - complete block + await cline.say("error", securityCheck.message) + updateOperationResult(relPath, { + status: "blocked", + error: "Access denied to confidential file", + }) + continue + } else if (securityCheck?.requiresApproval) { + // Sensitive files - user approval required + const { response } = await cline.ask("command", securityCheck.message) + if (response !== "yesButtonClicked") { + await cline.say("error", "Access denied to sensitive file") + updateOperationResult(relPath, { + status: "blocked", + error: "Access denied to sensitive file", + }) + continue + } + // User approved sensitive file access - continue with operation + } + // Check if file is write-protected const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 6de8dd5642..7c90a40a5c 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -246,8 +246,58 @@ export async function readFileTool( continue } - // Add to files that need approval - filesToApprove.push(fileResult) + // Check security validation + const securityCheck = cline.securityGuard?.validateFileAccess?.(relPath) + if (securityCheck?.blocked) { + // Confidential files - IMMEDIATE BLOCK with no permission dialog + await cline.say( + "error", + `🚫 **ACCESS DENIED**: Cannot access confidential file.\n\n📁 **File**: \`${relPath}\`\n🛡️ **Security Pattern**: \`${securityCheck.pattern}\`\n- Matched Rule: ${securityCheck.matchedRule}`, + ) + + updateFileResult(relPath, { + status: "blocked", + error: "Access denied to confidential file", + xmlContent: `${relPath}Access denied to confidential file`, + }) + continue + } else if (securityCheck?.requiresApproval) { + // First, inform the user in the chat about the sensitive file + await cline.say( + "text", + `🛡️ **PERMISSION REQUIRED**: Sensitive file requires approval for AI access.\n\n📁 **File**: \`${relPath}\`\n🛡️ **Security Pattern**: \`${securityCheck.pattern}\`\n- Matched Rule: ${securityCheck.matchedRule}`, + ) + + // Then ask for user permission using askApproval function + const permissionMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(cline.cwd, relPath), + isOutsideWorkspace: isPathOutsideWorkspace(path.resolve(cline.cwd, relPath)), + content: `� SECURITY PERMISSION REQUIRED 🔒\n\nThe AI is requesting access to a SENSITIVE file:\n\n📁 File: ${relPath}\n🛡️ Security Pattern: ${securityCheck.pattern}\n⚠️ Risk: This file may contain sensitive information like environment variables, tokens, or credentials.\n\n❓ Do you want to ALLOW the AI to read this sensitive file?\n\n✅ Click AGREE to grant access\n❌ Click REJECT to deny access`, + reason: `🔒 Sensitive File Access Required (${securityCheck.pattern})`, + } satisfies ClineSayTool) + + const didApprove = await askApproval("tool", permissionMessage, undefined, true) + + if (!didApprove) { + // User denied access + updateFileResult(relPath, { + status: "blocked", + error: "Access denied to sensitive file", + xmlContent: `${relPath}Access denied to sensitive file`, + }) + continue + } + + // User approved - mark as approved and skip additional approval + updateFileResult(relPath, { status: "approved" }) + } else { + // Handle files with no security restrictions (restructured for merge safety) + if (!securityCheck?.blocked && !securityCheck?.requiresApproval) { + // No security restrictions - add to files that need standard approval + filesToApprove.push(fileResult) + } + } } } diff --git a/src/core/tools/searchAndReplaceTool.ts b/src/core/tools/searchAndReplaceTool.ts index b6ec3ed39b..88a5003aea 100644 --- a/src/core/tools/searchAndReplaceTool.ts +++ b/src/core/tools/searchAndReplaceTool.ts @@ -124,6 +124,24 @@ export async function searchAndReplaceTool( return } + // Check security validation + const securityCheck = cline.securityGuard.validateFileAccess(validRelPath) + if (securityCheck?.blocked) { + // Confidential files - complete block + await cline.say("error", securityCheck.message) + pushToolResult(formatResponse.toolError("Access denied to confidential file")) + return + } else if (securityCheck?.requiresApproval) { + // Sensitive files - user approval required + const { response } = await cline.ask("command", securityCheck.message) + if (response !== "yesButtonClicked") { + await cline.say("error", "Access denied to sensitive file") + pushToolResult(formatResponse.toolError("Access denied to sensitive file")) + return + } + // User approved sensitive file access - continue with operation + } + // Check if file is write-protected const isWriteProtected = cline.rooProtectedController?.isWriteProtected(validRelPath) || false diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts index fd9d158f3f..bdd70d7b53 100644 --- a/src/core/tools/writeToFileTool.ts +++ b/src/core/tools/writeToFileTool.ts @@ -57,6 +57,50 @@ export async function writeToFileTool( return } + // Check security validation + const securityCheck = cline.securityGuard?.validateFileAccess?.(relPath) + let securityApproved = false // Track if security approval was granted + + if (securityCheck?.blocked) { + // Confidential files - IMMEDIATE BLOCK with no permission dialog + await cline.say( + "error", + `🚫 **ACCESS DENIED**: Cannot write to confidential file.\n\n📁 **File**: \`${relPath}\`\n🛡️ **Security Pattern**: \`${securityCheck.pattern}\`\n- Matched Rule: ${securityCheck.matchedRule}`, + ) + pushToolResult(formatResponse.toolError("Access denied to confidential file")) + return + } else if (securityCheck?.requiresApproval) { + // First, inform the user in the chat about the sensitive file + await cline.say( + "text", + `🔒 **SECURITY NOTICE**: I need to write to a sensitive file that requires your permission.\n\n📁 **File**: \`${relPath}\`\n🛡️ **Security Pattern**: \`${securityCheck.pattern}\`\n⚠️ **Risk**: This file may contain sensitive information like environment variables, tokens, or credentials.\n\nI will now request your permission to write to this file. Please review the permission dialog carefully.`, + ) + + // Check if file exists to determine the correct tool type + const absolutePath = path.resolve(cline.cwd, relPath) + const fileExists = await fileExistsAtPath(absolutePath) + + // Then ask for user permission using askApproval function + const permissionMessage = JSON.stringify({ + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(cline.cwd, relPath), + isOutsideWorkspace: isPathOutsideWorkspace(path.resolve(cline.cwd, relPath)), + content: `🔒 SECURITY PERMISSION REQUIRED 🔒\n\nThe AI is requesting permission to WRITE to a SENSITIVE file:\n\n📁 File: ${relPath}\n🛡️ Security Pattern: ${securityCheck.pattern}\n⚠️ Risk: This file may contain sensitive information like environment variables, tokens, or credentials.\n\n❓ Do you want to ALLOW the AI to write to this sensitive file?\n\n✅ Click AGREE to grant access\n❌ Click REJECT to deny access`, + reason: `🔒 Sensitive File Write Access Required (${securityCheck.pattern})`, + } satisfies ClineSayTool) + + const didApprove = await askApproval("tool", permissionMessage, undefined, true) + + if (!didApprove) { + // User denied access + await cline.say("error", "Access denied to sensitive file") + pushToolResult(formatResponse.toolError("Access denied to sensitive file")) + return + } + // User approved sensitive file access - set flag to skip normal approval + securityApproved = true + } + // Check if file is write-protected const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false @@ -94,7 +138,7 @@ export async function writeToFileTool( path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)), content: newContent, isOutsideWorkspace, - isProtected: isWriteProtected, + isProtected: isWriteProtected || !!securityCheck?.requiresApproval || !!securityCheck?.blocked, } try { @@ -198,19 +242,22 @@ export async function writeToFileTool( } } - const completeMessage = JSON.stringify({ - ...sharedMessageProps, - content: fileExists ? undefined : newContent, - diff: fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) - : undefined, - } satisfies ClineSayTool) + // Skip normal approval if security approval was already granted + if (!securityApproved) { + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: fileExists ? undefined : newContent, + diff: fileExists + ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) + : undefined, + } satisfies ClineSayTool) - const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) - if (!didApprove) { - await cline.diffViewProvider.revertChanges() - return + if (!didApprove) { + await cline.diffViewProvider.revertChanges() + return + } } // Call saveChanges to update the DiffViewProvider properties diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6231f08167..0d3d9af167 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1440,6 +1440,7 @@ export class ClineProvider alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, diagnosticsEnabled, + securityCustomConfigPath, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1561,6 +1562,7 @@ export class ClineProvider alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, diagnosticsEnabled: diagnosticsEnabled ?? true, + securityCustomConfigPath: securityCustomConfigPath ?? "", } } @@ -1726,6 +1728,7 @@ export class ClineProvider codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, }, profileThresholds: stateValues.profileThresholds ?? {}, + securityCustomConfigPath: stateValues.securityCustomConfigPath, } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 780d40df89..059d7b9bed 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -209,6 +209,59 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "createGlobalSecurityConfig": + try { + const { SecurityGuard } = await import("../security/SecurityGuard") + const globalConfigPath = await SecurityGuard.createDefaultGlobalConfig() + + // Open the config file in VSCode editor + await openFile(globalConfigPath) + + vscode.window.showInformationMessage(`Global security config created at: ${globalConfigPath}`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to create global security config: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to create global security config: ${errorMessage}`) + } + break + case "createProjectSecurityConfig": + try { + const workspacePath = getWorkspacePath() + if (!workspacePath) { + vscode.window.showErrorMessage( + "No workspace open. Please open a project folder to create a project security config.", + ) + break + } + + const { SecurityGuard } = await import("../security/SecurityGuard") + const projectConfigPath = await SecurityGuard.createDefaultProjectConfig(workspacePath) + + // Open the config file in VSCode editor + await openFile(projectConfigPath) + + vscode.window.showInformationMessage(`Project security config created at: ${projectConfigPath}`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to create project security config: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to create project security config: ${errorMessage}`) + } + break + case "getSecurityConfigStatus": + try { + const { SecurityGuard } = await import("../security/SecurityGuard") + const workspacePath = getWorkspacePath() || provider.cwd || process.cwd() + const tempSecurityGuard = new SecurityGuard(workspacePath, false) + const configStatus = tempSecurityGuard.getConfigStatus() + await provider.postMessageToWebview({ + type: "securityConfigStatus", + configStatus, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Failed to get security config status: ${errorMessage}`) + } + break case "webviewDidLaunch": // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() @@ -340,6 +393,12 @@ export const webviewMessageHandler = async ( await updateGlobalState("alwaysAllowUpdateTodoList", message.bool) await provider.postStateToWebview() break + case "securityCustomConfigPath": + console.log(`[WebviewMessageHandler] Received securityCustomConfigPath: "${message.text}"`) + await updateGlobalState("securityCustomConfigPath", message.text || "") + console.log(`[WebviewMessageHandler] Saved securityCustomConfigPath to global state`) + await provider.postStateToWebview() + break case "askResponse": provider.getCurrentCline()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) break diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4f2aa2da15..4792c9793a 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -105,6 +105,7 @@ export interface ExtensionMessage { | "shareTaskSuccess" | "codeIndexSettingsSaved" | "codeIndexSecretStatus" + | "securityConfigStatus" | "showDeleteMessageDialog" | "showEditMessageDialog" text?: string @@ -161,6 +162,14 @@ export interface ExtensionMessage { settings?: any messageTs?: number context?: string + configStatus?: { + globalPath: string + globalExists: boolean + projectPath: string + projectExists: boolean + customPath?: string + customExists?: boolean + } } export type ExtensionState = Pick< @@ -283,6 +292,7 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record hasOpenedModeSelector: boolean + securityCustomConfigPath?: string } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 1f56829f7b..d67de3b4f4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -26,6 +26,10 @@ export interface WebviewMessage { type: | "updateTodoList" | "deleteMultipleTasksWithIds" + | "createGlobalSecurityConfig" + | "createProjectSecurityConfig" + | "getSecurityConfigStatus" + | "securityConfigStatus" | "currentApiConfigName" | "saveApiConfiguration" | "upsertApiConfiguration" @@ -45,6 +49,7 @@ export interface WebviewMessage { | "alwaysAllowExecute" | "alwaysAllowFollowupQuestions" | "alwaysAllowUpdateTodoList" + | "securityCustomConfigPath" | "followupAutoApproveTimeoutMs" | "webviewDidLaunch" | "newTask" diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 4a8f06d62a..a568486a8e 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -23,11 +23,21 @@ describe("experiments", () => { }) }) + describe("SECURITY_MIDDLEWARE", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.SECURITY_MIDDLEWARE).toBe("securityMiddleware") + expect(experimentConfigsMap.SECURITY_MIDDLEWARE).toMatchObject({ + enabled: false, + }) + }) + }) + describe("isEnabled", () => { it("returns false when POWER_STEERING experiment is not enabled", () => { const experiments: Record = { powerSteering: false, multiFileApplyDiff: false, + securityMiddleware: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -36,6 +46,7 @@ describe("experiments", () => { const experiments: Record = { powerSteering: true, multiFileApplyDiff: false, + securityMiddleware: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -44,6 +55,7 @@ describe("experiments", () => { const experiments: Record = { powerSteering: false, multiFileApplyDiff: false, + securityMiddleware: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 1edadf654f..df9343996c 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -3,6 +3,7 @@ import type { AssertEqual, Equals, Keys, Values, ExperimentId, Experiments } fro export const EXPERIMENT_IDS = { MULTI_FILE_APPLY_DIFF: "multiFileApplyDiff", POWER_STEERING: "powerSteering", + SECURITY_MIDDLEWARE: "securityMiddleware", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -16,6 +17,7 @@ interface ExperimentConfig { export const experimentConfigsMap: Record = { MULTI_FILE_APPLY_DIFF: { enabled: false }, POWER_STEERING: { enabled: false }, + SECURITY_MIDDLEWARE: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index f804f7b61e..85c51c689f 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -993,7 +993,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction & { experiments: Experiments setExperimentEnabled: SetExperimentEnabled + securityCustomConfigPath?: string + setCachedStateField?: SetCachedStateField } export const ExperimentalSettings = ({ experiments, setExperimentEnabled, + securityCustomConfigPath: propSecurityCustomConfigPath, + setCachedStateField, className, ...props }: ExperimentalSettingsProps) => { const { t } = useAppTranslation() + const { securityCustomConfigPath: stateSecurityCustomConfigPath, setSecurityCustomConfigPath } = useExtensionState() + + // Use prop if provided, otherwise fall back to internal state + const securityCustomConfigPath = propSecurityCustomConfigPath ?? stateSecurityCustomConfigPath + + // Use prop function if provided, otherwise use internal state setter with auto-save + const updateSecurityCustomConfigPath = setCachedStateField + ? (value: string) => setCachedStateField("securityCustomConfigPath", value) + : (value: string) => { + setSecurityCustomConfigPath(value) + // Auto-save when using internal state management + vscode.postMessage({ type: "securityCustomConfigPath", text: value }) + } + + // Check if the security middleware is enabled + const isSecurityMiddlewareEnabled = experiments[EXPERIMENT_IDS.SECURITY_MIDDLEWARE] ?? false + + // Config status state + const [configStatus, setConfigStatus] = useState<{ + globalPath: string + globalExists: boolean + projectPath: string + projectExists: boolean + customPath?: string + customExists?: boolean + } | null>(null) + + // Parse encoded config path that includes enabled state + const parseConfigValue = (value: string): { enabled: boolean; path: string } => { + if (value.startsWith("DISABLED:")) { + return { enabled: false, path: value.substring(9) } + } + return { enabled: !!value, path: value } + } + + const { enabled: configEnabled, path: actualConfigPath } = parseConfigValue(securityCustomConfigPath || "") + const [enableCustomConfig, setEnableCustomConfig] = useState(configEnabled) + + // Transform full paths to use ~ notation + const transformPath = (fullPath: string): string => { + const homeDir = process.env.HOME || "/Users/" + process.env.USER + if (fullPath.startsWith(homeDir)) { + return fullPath.replace(homeDir, "~") + } + return fullPath + } + + // Update toggle when custom config path changes + useEffect(() => { + const { enabled } = parseConfigValue(securityCustomConfigPath || "") + setEnableCustomConfig(enabled) + }, [securityCustomConfigPath]) + + // Fetch config status when security middleware is enabled + useEffect(() => { + if (isSecurityMiddlewareEnabled) { + vscode.postMessage({ type: "getSecurityConfigStatus" }) + } + }, [isSecurityMiddlewareEnabled]) + + // Listen for config status response + useEffect(() => { + const messageListener = (event: MessageEvent) => { + if (event.data.type === "securityConfigStatus") { + setConfigStatus(event.data.configStatus) + } + } + window.addEventListener("message", messageListener) + return () => window.removeEventListener("message", messageListener) + }, []) return (
@@ -51,6 +128,131 @@ export const ExperimentalSettings = ({ /> ) } + + // Special handling for SECURITY_MIDDLEWARE - show custom path input when enabled + if (config[0] === "SECURITY_MIDDLEWARE") { + return ( +
+ + setExperimentEnabled(EXPERIMENT_IDS.SECURITY_MIDDLEWARE, enabled) + } + /> + {isSecurityMiddlewareEnabled && ( +
+
+
Config Management
+

+ Create security configuration files to customize allowed operations +

+ {configStatus && ( +
+
{ + if (configStatus.projectExists) { + vscode.postMessage({ + type: "openFile", + text: configStatus.projectPath, + }) + } else { + vscode.postMessage({ + type: "createProjectSecurityConfig", + }) + } + }}> + {configStatus.projectExists ? "✅" : "❌"} Project: + .roo/security.yaml +
+
{ + if (configStatus.globalExists) { + vscode.postMessage({ + type: "openFile", + text: configStatus.globalPath, + }) + } else { + vscode.postMessage({ + type: "createGlobalSecurityConfig", + }) + } + }}> + {configStatus.globalExists ? "✅" : "❌"} Global: + ~/.roo/security.yaml +
+ {configStatus.customPath && ( +
{ + vscode.postMessage({ + type: "openFile", + text: configStatus.customPath, + }) + }}> + {configStatus.customExists ? "✅" : "❌"}Custom:{" "} + {configStatus.customPath} +
+ )} +
+ )} +
+
+
+ { + const isEnabled = e.target.checked + setEnableCustomConfig(isEnabled) + + if (isEnabled) { + // Toggle ON: Remove DISABLED prefix (enables custom config) + updateSecurityCustomConfigPath(actualConfigPath) + } else { + // Toggle OFF: Add DISABLED prefix (disables but preserves path) + const currentPath = + actualConfigPath || securityCustomConfigPath || "" + updateSecurityCustomConfigPath( + `DISABLED:${currentPath}`, + ) + } + }} + className="w-3 h-3" + /> + + Enable custom global configs + + {enableCustomConfig && configStatus?.customExists && ( + + )} +
+ {enableCustomConfig && ( +
+ + updateSecurityCustomConfigPath(e.target.value) + } + style={{ width: "100%" }}> + Additional Security Config Path + +

+ Add path for additional YAML config file. For example: + ~/company-config/security.yaml +

+
+ )} +
+
+ )} +
+ ) + } + return ( (({ onDone, t alwaysAllowFollowupQuestions, alwaysAllowUpdateTodoList, followupAutoApproveTimeoutMs, + securityCustomConfigPath, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -333,6 +334,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "profileThresholds", values: profileThresholds }) + vscode.postMessage({ type: "securityCustomConfigPath", text: securityCustomConfigPath || "" }) setChangeDetected(false) } } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index c970733fba..1672b732bc 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -134,6 +134,8 @@ export interface ExtensionStateContextType extends ExtensionState { routerModels?: RouterModels alwaysAllowUpdateTodoList?: boolean setAlwaysAllowUpdateTodoList: (value: boolean) => void + securityCustomConfigPath?: string + setSecurityCustomConfigPath: (value: string) => void } export const ExtensionStateContext = createContext(undefined) @@ -229,6 +231,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, codebaseIndexModels: { ollama: {}, openai: {} }, alwaysAllowUpdateTodoList: true, + securityCustomConfigPath: "", }) const [didHydrateState, setDidHydrateState] = useState(false) @@ -474,6 +477,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAlwaysAllowUpdateTodoList: (value) => { setState((prevState) => ({ ...prevState, alwaysAllowUpdateTodoList: value })) }, + securityCustomConfigPath: state.securityCustomConfigPath, + setSecurityCustomConfigPath: (value) => { + setState((prevState) => ({ ...prevState, securityCustomConfigPath: value })) + vscode.postMessage({ type: "securityCustomConfigPath", text: value }) + }, } return {children} diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 1e5867d3fc..a7cfd1c13f 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -226,6 +226,7 @@ describe("mergeExtensionState", () => { disableCompletionCommand: false, concurrentFileReads: true, multiFileApplyDiff: true, + securityMiddleware: false, } as Record, } @@ -242,6 +243,7 @@ describe("mergeExtensionState", () => { disableCompletionCommand: false, concurrentFileReads: true, multiFileApplyDiff: true, + securityMiddleware: false, }) }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index cf17a6f919..0d7b5ba477 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Habilita edicions de fitxers concurrents", "description": "Quan està activat, Roo pot editar múltiples fitxers en una sola petició. Quan està desactivat, Roo ha d'editar fitxers d'un en un. Desactivar això pot ajudar quan es treballa amb models menys capaços o quan vols més control sobre les modificacions de fitxers." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware de Seguretat RooCode", + "description": "Habilitar middleware de seguretat per protegir fitxers confidencials i comandes de l'accés d'IA. Aquesta característica proporciona protecció de nivell empresarial contra l'accés no autoritzat a dades sensibles." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 20ffaec525..7e45083a38 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Gleichzeitige Dateibearbeitungen aktivieren", "description": "Wenn aktiviert, kann Roo mehrere Dateien in einer einzigen Anfrage bearbeiten. Wenn deaktiviert, muss Roo Dateien einzeln bearbeiten. Das Deaktivieren kann hilfreich sein, wenn mit weniger fähigen Modellen gearbeitet wird oder wenn du mehr Kontrolle über Dateiänderungen haben möchtest." + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode Sicherheits-Middleware", + "description": "Sicherheits-Middleware aktivieren, um vertrauliche Dateien und Befehle vor KI-Zugriff zu schützen. Diese Funktion bietet Schutz auf Unternehmensebene gegen unbefugten Zugriff auf sensible Daten." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 4a826bddab..7f29c8a95d 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Enable concurrent file edits", "description": "When enabled, Roo can edit multiple files in a single request. When disabled, Roo must edit files one at a time. Disabling this can help when working with less capable models or when you want more control over file modifications." + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode Security MiddleWare", + "description": "Enable security middleware to protect confidential files and commands from AI access. This feature provides enterprise-grade protection against unauthorized access to sensitive data." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 4c4f24bb0f..b0e54f8fd1 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Habilitar ediciones de archivos concurrentes", "description": "Cuando está habilitado, Roo puede editar múltiples archivos en una sola solicitud. Cuando está deshabilitado, Roo debe editar archivos de uno en uno. Deshabilitar esto puede ayudar cuando trabajas con modelos menos capaces o cuando quieres más control sobre las modificaciones de archivos." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware de Seguridad RooCode", + "description": "Habilitar middleware de seguridad para proteger archivos confidenciales y comandos del acceso de IA. Esta característica proporciona protección de nivel empresarial contra el acceso no autorizado a datos sensibles." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 6e7a964694..5720d4dc0a 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Activer les éditions de fichiers concurrentes", "description": "Lorsque cette option est activée, Roo peut éditer plusieurs fichiers en une seule requête. Lorsqu'elle est désactivée, Roo doit éditer les fichiers un par un. Désactiver cette option peut aider lorsque tu travailles avec des modèles moins capables ou lorsque tu veux plus de contrôle sur les modifications de fichiers." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware de Sécurité RooCode", + "description": "Activer le middleware de sécurité pour protéger les fichiers confidentiels et les commandes de l'accès IA. Cette fonctionnalité offre une protection de niveau entreprise contre l'accès non autorisé aux données sensibles." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index ca255ca44e..b851b08498 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "समानांतर फ़ाइल संपादन सक्षम करें", "description": "जब सक्षम किया जाता है, तो Roo एक ही अनुरोध में कई फ़ाइलों को संपादित कर सकता है। जब अक्षम किया जाता है, तो Roo को एक समय में एक फ़ाइल संपादित करनी होगी। इसे अक्षम करना तब मदद कर सकता है जब आप कम सक्षम मॉडल के साथ काम कर रहे हों या जब आप फ़ाइल संशोधनों पर अधिक नियंत्रण चाहते हों।" + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode सुरक्षा मिडलवेयर", + "description": "गोपनीय फाइलों और कमांड को AI पहुंच से सुरक्षित करने के लिए सुरक्षा मिडलवेयर सक्षम करें। यह सुविधा संवेदनशील डेटा तक अनधिकृत पहुंच के विरुद्ध एंटरप्राइज़-ग्रेड सुरक्षा प्रदान करती है।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 0bff5ef4a1..d3c90f94c4 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -647,6 +647,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Aktifkan edit file bersamaan", "description": "Ketika diaktifkan, Roo dapat mengedit beberapa file dalam satu permintaan. Ketika dinonaktifkan, Roo harus mengedit file satu per satu. Menonaktifkan ini dapat membantu saat bekerja dengan model yang kurang mampu atau ketika kamu ingin kontrol lebih terhadap modifikasi file." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware Keamanan RooCode", + "description": "Aktifkan middleware keamanan untuk melindungi file rahasia dan perintah dari akses AI. Fitur ini menyediakan perlindungan tingkat enterprise terhadap akses tidak sah ke data sensitif." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index e5cd37a110..848ddf4a27 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Abilita modifiche di file concorrenti", "description": "Quando abilitato, Roo può modificare più file in una singola richiesta. Quando disabilitato, Roo deve modificare i file uno alla volta. Disabilitare questa opzione può aiutare quando lavori con modelli meno capaci o quando vuoi più controllo sulle modifiche dei file." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware di Sicurezza RooCode", + "description": "Abilita il middleware di sicurezza per proteggere file riservati e comandi dall'accesso dell'IA. Questa funzionalità fornisce protezione di livello aziendale contro l'accesso non autorizzato a dati sensibili." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index e32fac776a..20538bf2b3 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "同時ファイル編集を有効にする", "description": "有効にすると、Rooは単一のリクエストで複数のファイルを編集できます。無効にすると、Rooはファイルを一つずつ編集する必要があります。これを無効にすることで、能力の低いモデルで作業する場合や、ファイル変更をより細かく制御したい場合に役立ちます。" + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode セキュリティミドルウェア", + "description": "機密ファイルとコマンドをAIアクセスから保護するセキュリティミドルウェアを有効にします。この機能は、機密データへの不正アクセスに対するエンタープライズグレードの保護を提供します。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index f48860b0ae..92bfc832cf 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "동시 파일 편집 활성화", "description": "활성화하면 Roo가 단일 요청으로 여러 파일을 편집할 수 있습니다. 비활성화하면 Roo는 파일을 하나씩 편집해야 합니다. 이 기능을 비활성화하면 덜 강력한 모델로 작업하거나 파일 수정에 대한 더 많은 제어가 필요할 때 도움이 됩니다." + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode 보안 미들웨어", + "description": "기밀 파일과 명령을 AI 접근으로부터 보호하기 위한 보안 미들웨어를 활성화합니다. 이 기능은 민감한 데이터에 대한 무단 접근에 대해 엔터프라이즈급 보호를 제공합니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index bf6e65c995..fe836327f0 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Gelijktijdige bestandsbewerkingen inschakelen", "description": "Wanneer ingeschakeld, kan Roo meerdere bestanden in één verzoek bewerken. Wanneer uitgeschakeld, moet Roo bestanden één voor één bewerken. Het uitschakelen hiervan kan helpen wanneer je werkt met minder capabele modellen of wanneer je meer controle wilt over bestandswijzigingen." + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode Beveiligingsmiddleware", + "description": "Schakel beveiligingsmiddleware in om vertrouwelijke bestanden en commando's te beschermen tegen AI-toegang. Deze functie biedt beveiliging op ondernemingsniveau tegen ongeautoriseerde toegang tot gevoelige gegevens." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index d42e4f51a9..264568a773 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Włącz równoczesne edycje plików", "description": "Gdy włączone, Roo może edytować wiele plików w jednym żądaniu. Gdy wyłączone, Roo musi edytować pliki jeden po drugim. Wyłączenie tego może pomóc podczas pracy z mniej zdolnymi modelami lub gdy chcesz mieć większą kontrolę nad modyfikacjami plików." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware Bezpieczeństwa RooCode", + "description": "Włącz middleware bezpieczeństwa, aby chronić poufne pliki i polecenia przed dostępem AI. Ta funkcja zapewnia ochronę na poziomie przedsiębiorstwa przed nieuprawnionym dostępem do wrażliwych danych." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 924542cf09..3e15f58740 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Habilitar edições de arquivos concorrentes", "description": "Quando habilitado, o Roo pode editar múltiplos arquivos em uma única solicitação. Quando desabilitado, o Roo deve editar arquivos um de cada vez. Desabilitar isso pode ajudar ao trabalhar com modelos menos capazes ou quando você quer mais controle sobre modificações de arquivos." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware de Segurança RooCode", + "description": "Ativar middleware de segurança para proteger arquivos confidenciais e comandos do acesso de IA. Este recurso fornece proteção de nível empresarial contra acesso não autorizado a dados sensíveis." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index cf719b0976..0ebba5ef98 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Включить одновременное редактирование файлов", "description": "Когда включено, Roo может редактировать несколько файлов в одном запросе. Когда отключено, Roo должен редактировать файлы по одному. Отключение этой функции может помочь при работе с менее способными моделями или когда вы хотите больше контроля над изменениями файлов." + }, + "SECURITY_MIDDLEWARE": { + "name": "Middleware Безопасности RooCode", + "description": "Включить middleware безопасности для защиты конфиденциальных файлов и команд от доступа ИИ. Эта функция обеспечивает защиту корпоративного уровня против несанкционированного доступа к чувствительным данным." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index fc8fc9c677..f478773ffe 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Eşzamanlı dosya düzenlemelerini etkinleştir", "description": "Etkinleştirildiğinde, Roo tek bir istekte birden fazla dosyayı düzenleyebilir. Devre dışı bırakıldığında, Roo dosyaları tek tek düzenlemek zorundadır. Bunu devre dışı bırakmak, daha az yetenekli modellerle çalışırken veya dosya değişiklikleri üzerinde daha fazla kontrol istediğinde yardımcı olabilir." + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode Güvenlik Middleware", + "description": "Gizli dosyaları ve komutları YZ erişiminden korumak için güvenlik middleware'ini etkinleştir. Bu özellik, hassas verilere yetkisiz erişime karşı kurumsal düzeyde koruma sağlar." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 7d3e2803ad..b7699d770d 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "Bật chỉnh sửa tệp đồng thời", "description": "Khi được bật, Roo có thể chỉnh sửa nhiều tệp trong một yêu cầu duy nhất. Khi bị tắt, Roo phải chỉnh sửa từng tệp một. Tắt tính năng này có thể hữu ích khi làm việc với các mô hình kém khả năng hơn hoặc khi bạn muốn kiểm soát nhiều hơn đối với các thay đổi tệp." + }, + "SECURITY_MIDDLEWARE": { + "name": "Phần mềm trung gian Bảo mật RooCode", + "description": "Kích hoạt phần mềm trung gian bảo mật để bảo vệ các tệp bí mật và lệnh khỏi việc truy cập của AI. Tính năng này cung cấp bảo vệ cấp doanh nghiệp chống lại việc truy cập trái phép vào dữ liệu nhạy cảm." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index eae71ac706..02be60c491 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "启用并发文件编辑", "description": "启用后 Roo 可在单个请求中编辑多个文件。禁用后 Roo 必须逐个编辑文件。禁用此功能有助于使用能力较弱的模型或需要更精确控制文件修改时。" + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode 安全中间件", + "description": "启用安全中间件以保护机密文件和命令免受AI访问。此功能提供企业级保护,防止对敏感数据的未授权访问。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 420ece916e..cfcb62e5ac 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -618,6 +618,10 @@ "MULTI_FILE_APPLY_DIFF": { "name": "啟用並行檔案編輯", "description": "啟用後 Roo 可在單個請求中編輯多個檔案。停用後 Roo 必須逐個編輯檔案。停用此功能有助於使用能力較弱的模型或需要更精確控制檔案修改時。" + }, + "SECURITY_MIDDLEWARE": { + "name": "RooCode 安全中介軟體", + "description": "啟用安全中介軟體以保護機密檔案和指令免受 AI 存取。此功能提供企業級防護,防止未經授權存取敏感資料。" } }, "promptCaching": { From 02697484341bdefc6d9b79376b3d53bcb6a4fd22 Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Thu, 11 Sep 2025 15:57:08 -0500 Subject: [PATCH 2/8] fix: improved error logging for middleware --- src/core/security/SecurityGuard.ts | 59 +------------------- webview-ui/src/i18n/locales/en/settings.json | 2 +- 2 files changed, 3 insertions(+), 58 deletions(-) diff --git a/src/core/security/SecurityGuard.ts b/src/core/security/SecurityGuard.ts index 598ff40690..e1cdf8e91a 100644 --- a/src/core/security/SecurityGuard.ts +++ b/src/core/security/SecurityGuard.ts @@ -42,19 +42,12 @@ export class SecurityGuard { private ruleIndex: Map = new Map() constructor(cwd: string, isEnabled: boolean = false, customConfigPath?: string) { - console.log( - `[SecurityGuard] Constructor called - cwd: "${cwd}", isEnabled: ${isEnabled}, customConfigPath: "${customConfigPath || "none"}"`, - ) - this.cwd = cwd this.isEnabled = isEnabled this.customConfigPath = customConfigPath if (this.isEnabled) { - console.log("[SecurityGuard] Security is enabled, calling loadConfiguration()") this.loadConfiguration() - } else { - console.log("[SecurityGuard] Security is disabled, skipping loadConfiguration()") } } @@ -69,11 +62,8 @@ export class SecurityGuard { let customConfig = {} if (this.customConfigPath) { - console.log(`[SecurityGuard] Processing custom config path: "${this.customConfigPath}"`) - // Skip disabled custom configs if (this.customConfigPath.startsWith("DISABLED:")) { - console.log("[SecurityGuard] Custom config is disabled, skipping") customConfig = {} } else { // Resolve custom config path to handle ~ and relative paths @@ -81,23 +71,16 @@ export class SecurityGuard { // Expand ~ to home directory if (resolvedCustomPath.startsWith("~")) { - console.log(`[SecurityGuard] Expanding ~ in path: "${resolvedCustomPath}"`) resolvedCustomPath = resolvedCustomPath.replace("~", os.homedir()) - console.log(`[SecurityGuard] After ~ expansion: "${resolvedCustomPath}"`) } // Resolve relative paths to absolute paths if (!path.isAbsolute(resolvedCustomPath)) { - console.log(`[SecurityGuard] Resolving relative path: "${resolvedCustomPath}"`) resolvedCustomPath = path.resolve(resolvedCustomPath) - console.log(`[SecurityGuard] After path resolution: "${resolvedCustomPath}"`) } - console.log(`[SecurityGuard] Final resolved custom config path: "${resolvedCustomPath}"`) customConfig = this.loadConfigFile(resolvedCustomPath) } - } else { - console.log("[SecurityGuard] No custom config path provided") } this.mergeConfigurations(globalConfig, projectConfig, customConfig) @@ -140,23 +123,13 @@ export class SecurityGuard { } private loadConfigFile(configPath: string): SecurityConfiguration { try { - console.log(`[SecurityGuard] Attempting to load config: ${configPath}`) - if (!fs.existsSync(configPath)) { - console.log(`[SecurityGuard] Config file does not exist: ${configPath}`) // No auto-creation - user must explicitly create config files via UI buttons return {} } - console.log(`[SecurityGuard] Config file exists, reading: ${configPath}`) const yamlContent = fs.readFileSync(configPath, "utf8") - console.log(`[SecurityGuard] Read ${yamlContent.length} characters from: ${configPath}`) - const config = yaml.parse(yamlContent) as SecurityConfiguration - console.log( - `[SecurityGuard] Successfully parsed config from: ${configPath}`, - JSON.stringify(config, null, 2), - ) return config } catch (error) { @@ -170,11 +143,6 @@ export class SecurityGuard { project: SecurityConfiguration, custom: SecurityConfiguration = {}, ): void { - console.log("[SecurityGuard] Starting mergeConfigurations") - console.log("[SecurityGuard] Global config:", JSON.stringify(global, null, 2)) - console.log("[SecurityGuard] Project config:", JSON.stringify(project, null, 2)) - console.log("[SecurityGuard] Custom config:", JSON.stringify(custom, null, 2)) - // Handle null/undefined configs by converting to empty objects const safeGlobal = global || {} const safeProject = project || {} @@ -210,37 +178,16 @@ export class SecurityGuard { ...(safeCustom.ask?.commands || []), ] - console.log("[SecurityGuard] Merged block files:", allBlockFiles) - console.log("[SecurityGuard] Merged block commands:", allBlockCommands) - console.log("[SecurityGuard] Merged ask files:", allAskFiles) - console.log("[SecurityGuard] Merged ask commands:", allAskCommands) - this.confidentialFiles = [...new Set(allBlockFiles)] this.confidentialCommands = [...new Set(allBlockCommands)] this.confidentialEnvVars = [...new Set(allBlockEnvVars)] this.sensitiveFiles = [...new Set(allAskFiles)] this.sensitiveCommands = [...new Set(allAskCommands)] - console.log( - "[SecurityGuard] Before filtering - confidentialFiles:", - this.confidentialFiles.length, - "sensitiveFiles:", - this.sensitiveFiles.length, - ) - this.sensitiveFiles = this.sensitiveFiles.filter((pattern) => !this.confidentialFiles.includes(pattern)) this.sensitiveCommands = this.sensitiveCommands.filter( (pattern) => !this.confidentialCommands.includes(pattern), ) - - console.log( - "[SecurityGuard] After filtering - confidentialFiles:", - this.confidentialFiles.length, - "sensitiveFiles:", - this.sensitiveFiles.length, - ) - console.log("[SecurityGuard] Final confidentialFiles:", this.confidentialFiles) - console.log("[SecurityGuard] Final sensitiveFiles:", this.sensitiveFiles) } public static createDefaultGlobalConfig(): string { const configPath = path.join(os.homedir(), ".roo", "security.yaml") @@ -286,7 +233,7 @@ ask: fs.writeFileSync(configPath, defaultGlobalConfig) return configPath } catch (error) { - // Config creation failed - continue silently + console.error(`[SecurityGuard] Failed to create default global config at ${configPath}:`, error) return configPath } } @@ -334,7 +281,7 @@ ask: fs.writeFileSync(configPath, defaultProjectConfig) return configPath } catch (error) { - // Config creation failed - continue silently + console.error(`[SecurityGuard] Failed to create default project config at ${configPath}:`, error) return configPath } } @@ -984,8 +931,6 @@ ask: this.confidentialEnvVars.forEach((pattern, index) => { this.ruleIndex.set(pattern, `block.env_vars[${index}]`) }) - - console.log(`[SecurityGuard] Built rule index with ${this.ruleIndex.size} patterns`) } /** diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7f29c8a95d..508b18d761 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -620,7 +620,7 @@ "description": "When enabled, Roo can edit multiple files in a single request. When disabled, Roo must edit files one at a time. Disabling this can help when working with less capable models or when you want more control over file modifications." }, "SECURITY_MIDDLEWARE": { - "name": "RooCode Security MiddleWare", + "name": "RooCode Security Middleware", "description": "Enable security middleware to protect confidential files and commands from AI access. This feature provides enterprise-grade protection against unauthorized access to sensitive data." } }, From 2f616fc4d9d58d508995ff77229fca04549076bb Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Thu, 11 Sep 2025 16:21:00 -0500 Subject: [PATCH 3/8] fixed error handling --- src/core/security/SecurityGuard.ts | 12 +++++++----- src/core/tools/readFileTool.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/core/security/SecurityGuard.ts b/src/core/security/SecurityGuard.ts index e1cdf8e91a..69c3754f37 100644 --- a/src/core/security/SecurityGuard.ts +++ b/src/core/security/SecurityGuard.ts @@ -83,11 +83,12 @@ export class SecurityGuard { } } - this.mergeConfigurations(globalConfig, projectConfig, customConfig) - this.buildRuleIndex() - } catch (error) { - this.loadLegacyConfiguration() - } + this.mergeConfigurations(globalConfig, projectConfig, customConfig) + this.buildRuleIndex() + } catch (error) { + console.error(`[SecurityGuard] Error loading security configuration:`, error) + this.loadLegacyConfiguration() + } } private getGlobalConfigPath(): string { @@ -877,6 +878,7 @@ ask: // Exact match return normalizedFile === normalizedPattern } catch (error) { + console.error(`[SecurityGuard] Error in pattern matching for '${filePath}' against '${pattern}':`, error) return false } } diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 7c90a40a5c..5f6d26ec1e 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -273,7 +273,7 @@ export async function readFileTool( tool: "readFile", path: getReadablePath(cline.cwd, relPath), isOutsideWorkspace: isPathOutsideWorkspace(path.resolve(cline.cwd, relPath)), - content: `� SECURITY PERMISSION REQUIRED 🔒\n\nThe AI is requesting access to a SENSITIVE file:\n\n📁 File: ${relPath}\n🛡️ Security Pattern: ${securityCheck.pattern}\n⚠️ Risk: This file may contain sensitive information like environment variables, tokens, or credentials.\n\n❓ Do you want to ALLOW the AI to read this sensitive file?\n\n✅ Click AGREE to grant access\n❌ Click REJECT to deny access`, + content: `SECURITY PERMISSION REQUIRED 🔒\n\nThe AI is requesting access to a SENSITIVE file:\n\n📁 File: ${relPath}\n🛡️ Security Pattern: ${securityCheck.pattern}\n⚠️ Risk: This file may contain sensitive information like environment variables, tokens, or credentials.\n\n❓ Do you want to ALLOW the AI to read this sensitive file?\n\n✅ Click AGREE to grant access\n❌ Click REJECT to deny access`, reason: `🔒 Sensitive File Access Required (${securityCheck.pattern})`, } satisfies ClineSayTool) From 679e059a2db0d6596683ffdc1660324abf6a1753 Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Fri, 12 Sep 2025 14:52:34 -0500 Subject: [PATCH 4/8] resolved conflict in ca/settings.json --- webview-ui/src/i18n/locales/ca/settings.json | 155 ++++++++++++++++++- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 0d7b5ba477..5dd38e9028 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -50,6 +50,9 @@ "mistralProvider": "Mistral", "mistralApiKeyLabel": "Clau de l'API:", "mistralApiKeyPlaceholder": "Introduïu la vostra clau de l'API de Mistral", + "vercelAiGatewayProvider": "Vercel AI Gateway", + "vercelAiGatewayApiKeyLabel": "Clau API", + "vercelAiGatewayApiKeyPlaceholder": "Introduïu la vostra clau API de Vercel AI Gateway", "openaiCompatibleProvider": "Compatible amb OpenAI", "openAiKeyLabel": "Clau API OpenAI", "openAiKeyPlaceholder": "Introduïu la vostra clau API OpenAI", @@ -113,6 +116,7 @@ "modelDimensionRequired": "Cal una dimensió de model", "geminiApiKeyRequired": "Cal una clau d'API de Gemini", "mistralApiKeyRequired": "La clau de l'API de Mistral és requerida", + "vercelAiGatewayApiKeyRequired": "Es requereix la clau API de Vercel AI Gateway", "ollamaBaseUrlRequired": "Cal una URL base d'Ollama", "baseUrlRequired": "Cal una URL base", "modelDimensionMinValue": "La dimensió del model ha de ser superior a 0" @@ -127,6 +131,7 @@ }, "autoApprove": { "description": "Permet que Roo realitzi operacions automàticament sense requerir aprovació. Activeu aquesta configuració només si confieu plenament en la IA i enteneu els riscos de seguretat associats.", + "enabled": "Auto-aprovació activada", "toggleAriaLabel": "Commuta l'aprovació automàtica", "disabledAriaLabel": "Aprovació automàtica desactivada: seleccioneu primer les opcions", "readOnly": { @@ -197,7 +202,14 @@ "description": "Fes aquesta quantitat de sol·licituds API automàticament abans de demanar aprovació per continuar amb la tasca.", "unlimited": "Il·limitat" }, - "selectOptionsFirst": "Seleccioneu almenys una opció a continuació per activar l'aprovació automàtica" + "selectOptionsFirst": "Seleccioneu almenys una opció a continuació per activar l'aprovació automàtica", + "apiCostLimit": { + "title": "Cost Màxim", + "unlimited": "Il·limitat" + }, + "maxLimits": { + "description": "Fes sol·licituds automàticament fins a aquests límits abans de demanar aprovació per continuar." + } }, "providers": { "providerDocumentation": "Documentació de {{provider}}", @@ -225,6 +237,8 @@ "awsCustomArnDesc": "Assegureu-vos que la regió a l'ARN coincideix amb la regió d'AWS seleccionada anteriorment.", "openRouterApiKey": "Clau API d'OpenRouter", "getOpenRouterApiKey": "Obtenir clau API d'OpenRouter", + "vercelAiGatewayApiKey": "Clau API de Vercel AI Gateway", + "getVercelAiGatewayApiKey": "Obtenir clau API de Vercel AI Gateway", "apiKeyStorageNotice": "Les claus API s'emmagatzemen de forma segura a l'Emmagatzematge Secret de VSCode", "glamaApiKey": "Clau API de Glama", "getGlamaApiKey": "Obtenir clau API de Glama", @@ -245,20 +259,56 @@ "error": "No s'ha pogut actualitzar la llista de models. Si us plau, torneu-ho a provar." }, "getRequestyApiKey": "Obtenir clau API de Requesty", + "getRequestyBaseUrl": "URL base", + "requestyUseCustomBaseUrl": "Utilitza l'URL base personalitzada", "openRouterTransformsText": "Comprimir prompts i cadenes de missatges a la mida del context (Transformacions d'OpenRouter)", "anthropicApiKey": "Clau API d'Anthropic", "getAnthropicApiKey": "Obtenir clau API d'Anthropic", "anthropicUseAuthToken": "Passar la clau API d'Anthropic com a capçalera d'autorització en lloc de X-Api-Key", + "anthropic1MContextBetaLabel": "Activa la finestra de context d'1M (Beta)", + "anthropic1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4", + "awsBedrock1MContextBetaLabel": "Activa la finestra de context d'1M (Beta)", + "awsBedrock1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4", + "cerebrasApiKey": "Clau API de Cerebras", + "getCerebrasApiKey": "Obtenir clau API de Cerebras", "chutesApiKey": "Clau API de Chutes", "getChutesApiKey": "Obtenir clau API de Chutes", + "fireworksApiKey": "Clau API de Fireworks", + "getFireworksApiKey": "Obtenir clau API de Fireworks", + "featherlessApiKey": "Clau API de Featherless", + "getFeatherlessApiKey": "Obtenir clau API de Featherless", + "ioIntelligenceApiKey": "Clau API d'IO Intelligence", + "ioIntelligenceApiKeyPlaceholder": "Introdueix la teva clau d'API de IO Intelligence", + "getIoIntelligenceApiKey": "Obtenir clau API d'IO Intelligence", "deepSeekApiKey": "Clau API de DeepSeek", "getDeepSeekApiKey": "Obtenir clau API de DeepSeek", + "doubaoApiKey": "Clau API de Doubao", + "getDoubaoApiKey": "Obtenir clau API de Doubao", "moonshotApiKey": "Clau API de Moonshot", "getMoonshotApiKey": "Obtenir clau API de Moonshot", "moonshotBaseUrl": "Punt d'entrada de Moonshot", + "zaiApiKey": "Clau API de Z AI", + "getZaiApiKey": "Obtenir clau API de Z AI", + "zaiEntrypoint": "Punt d'entrada de Z AI", + "zaiEntrypointDescription": "Si us plau, seleccioneu el punt d'entrada de l'API apropiat segons la vostra ubicació. Si sou a la Xina, trieu open.bigmodel.cn. Altrament, trieu api.z.ai.", "geminiApiKey": "Clau API de Gemini", "getGroqApiKey": "Obtenir clau API de Groq", "groqApiKey": "Clau API de Groq", + "getSambaNovaApiKey": "Obtenir clau API de SambaNova", + "sambaNovaApiKey": "Clau API de SambaNova", + "getHuggingFaceApiKey": "Obtenir clau API de Hugging Face", + "huggingFaceApiKey": "Clau API de Hugging Face", + "huggingFaceModelId": "ID del model", + "huggingFaceLoading": "Carregant...", + "huggingFaceModelsCount": "({{count}} models)", + "huggingFaceSelectModel": "Selecciona un model...", + "huggingFaceSearchModels": "Cerca models...", + "huggingFaceNoModelsFound": "No s'han trobat models", + "huggingFaceProvider": "Proveïdor", + "huggingFaceProviderAuto": "Automàtic", + "huggingFaceSelectProvider": "Selecciona un proveïdor...", + "huggingFaceSearchProviders": "Cerca proveïdors...", + "huggingFaceNoProvidersFound": "No s'han trobat proveïdors", "getGeminiApiKey": "Obtenir clau API de Gemini", "openAiApiKey": "Clau API d'OpenAI", "apiKey": "Clau API", @@ -274,6 +324,7 @@ "litellmBaseUrl": "URL base de LiteLLM", "awsCredentials": "Credencials d'AWS", "awsProfile": "Perfil d'AWS", + "awsApiKey": "Clau d'API d'Amazon Bedrock", "awsProfileName": "Nom del perfil d'AWS", "awsAccessKey": "Clau d'accés d'AWS", "awsSecretKey": "Clau secreta d'AWS", @@ -290,6 +341,16 @@ "cacheUsageNote": "Nota: Si no veieu l'ús de la caché, proveu de seleccionar un model diferent i després tornar a seleccionar el model desitjat.", "vscodeLmModel": "Model de llenguatge", "vscodeLmWarning": "Nota: Aquesta és una integració molt experimental i el suport del proveïdor variarà. Si rebeu un error sobre un model no compatible, és un problema del proveïdor.", + "geminiParameters": { + "urlContext": { + "title": "Activa el context d'URL", + "description": "Permet a Gemini llegir pàgines enllaçades per extreure, comparar i sintetitzar el seu contingut en respostes informades." + }, + "groundingSearch": { + "title": "Activa la Fonamentació amb la Cerca de Google", + "description": "Connecta Gemini a dades web en temps real per a respostes precises i actualitzades amb citacions verificables." + } + }, "googleCloudSetup": { "title": "Per utilitzar Google Cloud Vertex AI, necessiteu:", "step1": "1. Crear un compte de Google Cloud, habilitar l'API de Vertex AI i habilitar els models Claude necessaris.", @@ -313,6 +374,8 @@ "ollama": { "baseUrl": "URL base (opcional)", "modelId": "ID del model", + "apiKey": "Clau API d'Ollama", + "apiKeyHelp": "Clau API opcional per a instàncies d'Ollama autenticades o serveis al núvol. Deixa-ho buit per a instal·lacions locals.", "description": "Ollama permet executar models localment al vostre ordinador. Per a instruccions sobre com començar, consulteu la Guia d'inici ràpid.", "warning": "Nota: Roo Code utilitza prompts complexos i funciona millor amb models Claude. Els models menys capaços poden no funcionar com s'espera." }, @@ -324,6 +387,10 @@ "description": "No es requereix clau API, però l'usuari necessita ajuda per copiar i enganxar informació al xat d'IA web.", "instructions": "Durant l'ús, apareixerà un diàleg i el missatge actual es copiarà automàticament al porta-retalls. Necessiteu enganxar-lo a les versions web d'IA (com ChatGPT o Claude), després copiar la resposta de l'IA de nou al diàleg i fer clic al botó de confirmació." }, + "roo": { + "authenticatedMessage": "Autenticat de forma segura a través del teu compte de Roo Code Cloud.", + "connectButton": "Connecta amb Roo Code Cloud" + }, "openRouter": { "providerRouting": { "title": "Encaminament de Proveïdors d'OpenRouter", @@ -385,10 +452,18 @@ }, "reasoningEffort": { "label": "Esforç de raonament del model", + "minimal": "Mínim (el més ràpid)", "high": "Alt", "medium": "Mitjà", "low": "Baix" }, + "verbosity": { + "label": "Verbositat de la sortida", + "high": "Alta", + "medium": "Mitjana", + "low": "Baixa", + "description": "Controla el nivell de detall de les respostes del model. La verbositat baixa produeix respostes concises, mentre que la verbositat alta proporciona explicacions exhaustives." + }, "setReasoningLevel": "Activa l'esforç de raonament", "claudeCode": { "pathLabel": "Ruta del Codi Claude", @@ -488,6 +563,22 @@ "label": "Límit de lectures simultànies", "description": "Nombre màxim de fitxers que l'eina 'read_file' pot processar simultàniament. Els valors més alts poden accelerar la lectura de múltiples fitxers petits però augmenten l'ús de memòria." }, + "diagnostics": { + "includeMessages": { + "label": "Inclou automàticament diagnòstics al context", + "description": "Quan està activat, els missatges de diagnòstic (errors) dels fitxers editats s'inclouran automàticament al context. Sempre pots incloure manualment tots els diagnòstics de l'espai de treball utilitzant @problems." + }, + "maxMessages": { + "label": "Màxim de missatges de diagnòstic", + "description": "Nombre màxim de missatges de diagnòstic a incloure per fitxer. Aquest límit s'aplica tant a la inclusió automàtica (quan la casella està activada) com a les mencions manuals de @problems. Valors més alts proporcionen més context però augmenten l'ús de tokens.", + "resetTooltip": "Restablir al valor per defecte (50)", + "unlimitedLabel": "Il·limitat" + }, + "delayAfterWrite": { + "label": "Retard després d'escriptures per permetre que els diagnòstics detectin possibles problemes", + "description": "Temps d'espera després d'escriptures de fitxers abans de continuar, permetent que les eines de diagnòstic processin els canvis i detectin problemes." + } + }, "condensingThreshold": { "label": "Llindar d'activació de condensació", "selectProfile": "Configura el llindar per al perfil", @@ -496,6 +587,16 @@ "profileDescription": "Llindar personalitzat només per a aquest perfil (substitueix el per defecte global)", "inheritDescription": "Aquest perfil hereta el llindar per defecte global ({{threshold}}%)", "usesGlobal": "(utilitza global {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Mida màxima d'arxiu d'imatge", + "mb": "MB", + "description": "Mida màxima (en MB) per a arxius d'imatge que poden ser processats per l'eina de lectura d'arxius." + }, + "maxTotalImageSize": { + "label": "Mida total màxima d'imatges", + "mb": "MB", + "description": "Límit de mida acumulativa màxima (en MB) per a totes les imatges processades en una sola operació read_file. Quan es llegeixen múltiples imatges, la mida de cada imatge s'afegeix al total. Si incloure una altra imatge excediria aquest límit, serà omesa." } }, "terminal": { @@ -622,6 +723,33 @@ "SECURITY_MIDDLEWARE": { "name": "Middleware de Seguretat RooCode", "description": "Habilitar middleware de seguretat per protegir fitxers confidencials i comandes de l'accés d'IA. Aquesta característica proporciona protecció de nivell empresarial contra l'accés no autoritzat a dades sensibles." + }, + "PREVENT_FOCUS_DISRUPTION": { + "name": "Edició en segon pla", + "description": "Quan s'activa, evita la interrupció del focus de l'editor. Les edicions de fitxers es produeixen en segon pla sense obrir la vista diff o robar el focus. Pots continuar treballant sense interrupcions mentre Roo fa canvis. Els fitxers poden obrir-se sense focus per capturar diagnòstics o romandre completament tancats." + }, + "ASSISTANT_MESSAGE_PARSER": { + "name": "Utilitza el nou analitzador de missatges", + "description": "Activa l'analitzador de missatges en streaming experimental que millora el rendiment en respostes llargues processant els missatges de manera més eficient." + }, + "NEW_TASK_REQUIRE_TODOS": { + "name": "Requerir la llista 'todos' per a noves tasques", + "description": "Quan estigui activat, l'eina new_task requerirà que es proporcioni un paràmetre 'todos'. Això garanteix que totes les noves tasques comencin amb una llista clara d'objectius. Quan estigui desactivat (per defecte), el paràmetre 'todos' continua sent opcional per a la compatibilitat amb versions anteriors." + }, + "IMAGE_GENERATION": { + "name": "Habilitar generació d'imatges amb IA", + "description": "Quan estigui habilitat, Roo pot generar imatges a partir de prompts de text utilitzant els models de generació d'imatges d'OpenRouter. Requereix que es configuri una clau d'API d'OpenRouter.", + "openRouterApiKeyLabel": "Clau API d'OpenRouter", + "openRouterApiKeyPlaceholder": "Introdueix la teva clau API d'OpenRouter", + "getApiKeyText": "Obté la teva clau API de", + "modelSelectionLabel": "Model de generació d'imatges", + "modelSelectionDescription": "Selecciona el model per a la generació d'imatges", + "warningMissingKey": "⚠️ La clau API d'OpenRouter és necessària per a la generació d'imatges. Si us plau, configura-la a dalt.", + "successConfigured": "✓ La generació d'imatges està configurada i llesta per utilitzar" + }, + "RUN_SLASH_COMMAND": { + "name": "Habilitar comandes de barra diagonal iniciades pel model", + "description": "Quan està habilitat, Roo pot executar les vostres comandes de barra diagonal per executar fluxos de treball." } }, "promptCaching": { @@ -640,6 +768,7 @@ "noComputerUse": "No suporta ús de l'ordinador", "supportsPromptCache": "Suporta emmagatzematge en caché de prompts", "noPromptCache": "No suporta emmagatzematge en caché de prompts", + "contextWindow": "Finestra de context:", "maxOutput": "Sortida màxima", "inputPrice": "Preu d'entrada", "outputPrice": "Preu de sortida", @@ -667,7 +796,7 @@ "feedback": "Si teniu qualsevol pregunta o comentari, no dubteu a obrir un issue a github.com/RooCodeInc/Roo-Code o unir-vos a reddit.com/r/RooCode o discord.gg/roocode", "telemetry": { "label": "Permetre informes anònims d'errors i ús", - "description": "Ajudeu a millorar Roo Code enviant dades d'ús anònimes i informes d'errors. Mai s'envia codi, prompts o informació personal. Vegeu la nostra política de privacitat per a més detalls." + "description": "Ajuda a millorar Roo Code enviant dades d'ús anònimes i informes d'error. Aquesta telemetria no recull codi, prompts o informació personal. Consulta la nostra política de privacitat per a més detalls. Pots desactivar-ho en qualsevol moment." }, "settings": { "import": "Importar", @@ -693,7 +822,8 @@ "modelAvailability": "L'ID de model ({{modelId}}) que heu proporcionat no està disponible. Si us plau, trieu un altre model.", "providerNotAllowed": "El proveïdor '{{provider}}' no està permès per la vostra organització", "modelNotAllowed": "El model '{{model}}' no està permès per al proveïdor '{{provider}}' per la vostra organització", - "profileInvalid": "Aquest perfil conté un proveïdor o model que no està permès per la vostra organització" + "profileInvalid": "Aquest perfil conté un proveïdor o model que no està permès per la vostra organització", + "qwenCodeOauthPath": "Has de proporcionar una ruta vàlida de credencials OAuth" }, "placeholders": { "apiKey": "Introduïu la clau API...", @@ -729,5 +859,22 @@ "useCustomArn": "Utilitza ARN personalitzat..." }, "includeMaxOutputTokens": "Incloure tokens màxims de sortida", - "includeMaxOutputTokensDescription": "Enviar el paràmetre de tokens màxims de sortida a les sol·licituds API. Alguns proveïdors poden no admetre això." + "includeMaxOutputTokensDescription": "Enviar el paràmetre de tokens màxims de sortida a les sol·licituds API. Alguns proveïdors poden no admetre això.", + "limitMaxTokensDescription": "Limitar el nombre màxim de tokens en la resposta", + "maxOutputTokensLabel": "Tokens màxims de sortida", + "maxTokensGenerateDescription": "Tokens màxims a generar en la resposta", + "serviceTier": { + "label": "Nivell de servei", + "tooltip": "Per a un processament més ràpid de les sol·licituds de l'API, proveu el nivell de servei de processament prioritari. Per a preus més baixos amb una latència més alta, proveu el nivell de processament flexible.", + "standard": "Estàndard", + "flex": "Flex", + "priority": "Prioritat", + "pricingTableTitle": "Preus per nivell de servei (preu per 1M de fitxes)", + "columns": { + "tier": "Nivell", + "input": "Entrada", + "output": "Sortida", + "cacheReads": "Lectures de memòria cau" + } + } } From 01bfe1a7d59249192ee4eaf84c913959c977fe95 Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Mon, 15 Sep 2025 14:43:00 -0500 Subject: [PATCH 5/8] fixed lint issues --- src/core/tools/__tests__/multiApplyDiffTool.spec.ts | 4 ++++ webview-ui/src/components/chat/ChatView.tsx | 3 +-- .../src/components/settings/ExperimentalSettings.tsx | 11 +---------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts index 5e591f9fe7..dc2d5a2506 100644 --- a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts +++ b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts @@ -42,6 +42,7 @@ describe("multiApplyDiffTool", () => { getState: vi.fn().mockResolvedValue({ experiments: { [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, + [EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION]: false, }, diagnosticsEnabled: true, writeDelayMs: 0, @@ -87,6 +88,9 @@ describe("multiApplyDiffTool", () => { rooProtectedController: { isWriteProtected: vi.fn().mockReturnValue(false), }, + securityGuard: { + validateFileAccess: vi.fn().mockReturnValue(null), // null means no security issues + }, fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined), }, diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 7271588429..80dd6244aa 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -102,7 +102,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const homeDir = process.env.HOME || "/Users/" + process.env.USER - if (fullPath.startsWith(homeDir)) { - return fullPath.replace(homeDir, "~") - } - return fullPath - } - // Update toggle when custom config path changes useEffect(() => { const { enabled } = parseConfigValue(securityCustomConfigPath || "") From 4b9fd247553c0699fb796097b7f7041a0eaebdad Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Mon, 15 Sep 2025 15:25:25 -0500 Subject: [PATCH 6/8] fix for windows test...hopefully --- .../__tests__/custom-system-prompt.spec.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/core/prompts/__tests__/custom-system-prompt.spec.ts b/src/core/prompts/__tests__/custom-system-prompt.spec.ts index b2ae067a3a..361f2f808d 100644 --- a/src/core/prompts/__tests__/custom-system-prompt.spec.ts +++ b/src/core/prompts/__tests__/custom-system-prompt.spec.ts @@ -39,6 +39,23 @@ vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), })) +vi.mock("../../../services/code-index/manager", () => ({ + CodeIndexManager: { + getInstance: vi.fn().mockReturnValue({ + isFeatureEnabled: false, + isFeatureConfigured: false, + isInitialized: false, + getCurrentStatus: vi.fn().mockReturnValue({ + systemStatus: "Standby", + message: "", + }), + searchIndex: vi.fn().mockResolvedValue([]), + dispose: vi.fn(), + }), + disposeAll: vi.fn(), + }, +})) + import { SYSTEM_PROMPT } from "../system" import { defaultModeSlug, modes } from "../../../shared/modes" import * as vscode from "vscode" @@ -62,7 +79,7 @@ const mockContext = { globalState: { get: () => undefined, update: () => Promise.resolve(), - setKeysForSync: () => {}, + setKeysForSync: () => { }, }, extensionUri: { fsPath: "mock/extension/path" }, globalStorageUri: { fsPath: "mock/settings/path" }, From a464afd91925c7193974ea26e83bf7590ce9e6c1 Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Mon, 15 Sep 2025 15:43:05 -0500 Subject: [PATCH 7/8] increased timeout for windows test --- src/core/prompts/__tests__/custom-system-prompt.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/prompts/__tests__/custom-system-prompt.spec.ts b/src/core/prompts/__tests__/custom-system-prompt.spec.ts index 361f2f808d..3febcc8aea 100644 --- a/src/core/prompts/__tests__/custom-system-prompt.spec.ts +++ b/src/core/prompts/__tests__/custom-system-prompt.spec.ts @@ -131,7 +131,7 @@ describe("File-Based Custom System Prompt", () => { expect(prompt).toContain("CAPABILITIES") expect(prompt).toContain("MODES") expect(prompt).toContain("Test role definition") - }) + }, 50000) // 50 second timeout for Windows CI it("should use file-based custom system prompt when available", async () => { // Mock the readFile to return content from a file From e0e8d76be9e740cfdc6ab88934f7f0c54636afcc Mon Sep 17 00:00:00 2001 From: ThatChillGuy Date: Mon, 15 Sep 2025 16:05:38 -0500 Subject: [PATCH 8/8] fixed This does not escape backslash characters in the input --- src/core/security/SecurityGuard.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/security/SecurityGuard.ts b/src/core/security/SecurityGuard.ts index 69c3754f37..42914b0276 100644 --- a/src/core/security/SecurityGuard.ts +++ b/src/core/security/SecurityGuard.ts @@ -787,6 +787,7 @@ ask: */ private patternToRegex(pattern: string): RegExp { const escaped = pattern + .replace(/\\/g, "\\\\") // Escape backslashes FIRST .replace(/\./g, "\\.") // Escape dots .replace(/\*/g, ".*") // Convert * to .* @@ -868,6 +869,7 @@ ask: if (normalizedPattern.includes("*")) { // Convert glob pattern to regex const regexPattern = normalizedPattern + .replace(/\\/g, "\\\\") // Escape backslashes FIRST .replace(/\./g, "\\.") // Escape dots .replace(/\*/g, ".*") // Convert * to .*