diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a570ac9ec..07626a638 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -97,6 +97,26 @@ var ( regexp.MustCompile(`\bsource\s+.*\.sh\b`), } + // windowsDenyPatterns contains PowerShell-specific deny patterns that only + // apply on Windows, where commands are executed via powershell -Command. + windowsDenyPatterns = []*regexp.Regexp{ + // [Text.Encoding] used to construct command strings at runtime. + // Matches [Text.Encoding] and [System.Text.Encoding] variants. + regexp.MustCompile(`\[(?:\w+\.)?text\.encoding\]`), + // PowerShell -EncodedCommand flag (base64-encoded command) and all short forms. + // Matches: -e, -ec, -enc, -en, -EncodedCommand (all with space prefix) + regexp.MustCompile(` -e(?:$|\s)| -ec(?:$|\s)| -enc(?:$|\s)| -en(?:$|\s)| -encodedcommand\b`), + // .GetString called on byte array to decode commands. + regexp.MustCompile(`\.getstring\s*\(\s*\[byte\[\]`), + // FromBase64String used in command construction chain. + regexp.MustCompile(`frombase64string\(`), + // PowerShell variable holding byte array used in GetString. + regexp.MustCompile(`\$[a-zA-Z_]\w*\s*=\s*\[byte\[\]`), + // Unicode escape sequences that could be used to construct commands. + // Matches \uXXXX format used to represent characters like i = "i" + regexp.MustCompile(`\\u[0-9a-fA-F]{4}`), + } + // absolutePathPattern matches absolute file paths in commands (Unix and Windows). absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) @@ -138,6 +158,9 @@ func NewExecToolWithConfig( allowRemote = execConfig.AllowRemote if enableDenyPatterns { denyPatterns = append(denyPatterns, defaultDenyPatterns...) + if runtime.GOOS == "windows" { + denyPatterns = append(denyPatterns, windowsDenyPatterns...) + } if len(execConfig.CustomDenyPatterns) > 0 { fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) for _, pattern := range execConfig.CustomDenyPatterns { @@ -161,6 +184,9 @@ func NewExecToolWithConfig( } } else { denyPatterns = append(denyPatterns, defaultDenyPatterns...) + if runtime.GOOS == "windows" { + denyPatterns = append(denyPatterns, windowsDenyPatterns...) + } } var timeout time.Duration @@ -1019,6 +1045,30 @@ func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult { } } +// expandPowerShellEnvVars expands environment variable syntax used by both +// PowerShell ($env:VAR) and CMD (%VAR%) to their actual values. +func expandPowerShellEnvVars(cmd string) string { + // Handle PowerShell style: $env:VAR and ${env:VAR} + rePs := regexp.MustCompile(`\$\{?env:(\w+)\}?`) + cmd = rePs.ReplaceAllStringFunc(cmd, func(match string) string { + varName := rePs.FindStringSubmatch(match)[1] + if val := os.Getenv(varName); val != "" { + return val + } + return match + }) + + // Handle CMD style: %VAR% + reCmd := regexp.MustCompile(`%([^%]+)%`) + return reCmd.ReplaceAllStringFunc(cmd, func(match string) string { + varName := reCmd.FindStringSubmatch(match)[1] + if val := os.Getenv(varName); val != "" { + return val + } + return match + }) +} + func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) lower := strings.ToLower(cmd) @@ -1054,7 +1104,8 @@ func (t *ExecTool) guardCommand(command, cwd string) string { } if t.restrictToWorkspace { - if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") { + // Block path traversal patterns including .../.../ variants + if regexp.MustCompile(`\.\.(?:[\\/]\.\.)*[\\/]`).MatchString(cmd) { return "Command blocked by safety guard (path traversal detected)" } @@ -1068,6 +1119,16 @@ func (t *ExecTool) guardCommand(command, cwd string) string { // file:// URIs are still validated against the workspace boundary. webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"} + // On Windows, expand ~ and PowerShell environment variables ($env:VAR) before path checking + if runtime.GOOS == "windows" { + // Expand PowerShell environment variables ($env:VAR and ${env:VAR}) + cmd = expandPowerShellEnvVars(cmd) + // Also expand ~ for completeness + if home, err := os.UserHomeDir(); err == nil { + cmd = strings.ReplaceAll(cmd, "~", filepath.FromSlash(home)) + } + } + matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1) for _, loc := range matchIndices { @@ -1099,6 +1160,22 @@ func (t *ExecTool) guardCommand(command, cwd string) string { continue } + // Windows-specific: normalize paths to block ADS and extended-length paths + if runtime.GOOS == "windows" { + // Strip \\?\ prefix (extended-length path) + p = strings.TrimPrefix(p, `\\?\`) + // Strip NTFS alternate data streams (only if colon is not at position 1 = drive letter) + if idx := strings.Index(p, ":"); idx > 1 { + p = p[:idx] + } + } + + // Check symlinks and junctions + resolved, err := filepath.EvalSymlinks(p) + if err == nil { + p = resolved + } + if safePaths[p] { continue } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index a8de2f4c9..14d5a6697 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -703,6 +703,185 @@ func TestShellTool_URLBypassPrevented(t *testing.T) { } } +// TestWindows_TildeBypassPrevented verifies that ~ (home directory) cannot be +// used to escape workspace restrictions on Windows. +func TestWindows_TildeBypassPrevented(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + ctx := context.Background() + + // Tilde should be blocked when it expands outside workspace + blockedCommands := []string{ + "ls ~", + "ls ~/some/path", + "cat ~/.config/file", + // PowerShell environment variables also expand to home directory + "dir $env:USERPROFILE", + "ls $env:USERPROFILE", + "cat $env:USERPROFILE\\.config\\file", + // CMD environment variables + `cmd /c "dir %USERPROFILE%"`, + `cmd /c "cd %USERPROFILE% && dir"`, + `cmd /c "type %USERPROFILE%\\.config\\file"`, + } + + for _, cmd := range blockedCommands { + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { + t.Errorf("tilde bypass should be blocked: %q\n got: %s", cmd, result.ForLLM) + } + } +} + +// TestShellTool_PathTraversalVariants verifies that .../.../ and similar +// path traversal variants are blocked. +func TestShellTool_PathTraversalVariants(t *testing.T) { + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + ctx := context.Background() + + // Path traversal variants should be blocked + blockedCommands := []string{ + "ls .../.../", + "ls ..../..../", + "cat .../.../../../etc/passwd", + } + + for _, cmd := range blockedCommands { + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + if !result.IsError || !strings.Contains(result.ForLLM, "path traversal") { + t.Errorf("path traversal variant should be blocked: %q\n got: %s", cmd, result.ForLLM) + } + } + + // Legitimate commands with ... should not be blocked (if such commands exist) + // Note: these will fail for other reasons but should not be blocked by path traversal + allowedCommands := []string{ + "echo ...", + "ls ...", + } + + for _, cmd := range allowedCommands { + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + // These should not be blocked by path traversal check specifically + if strings.Contains(result.ForLLM, "path traversal") { + t.Errorf("legitimate command with ... should not be blocked: %q", cmd) + } + } +} + +// TestWindows_SymlinkBypassPrevented verifies that symlinks pointing outside +// workspace are detected and blocked after resolution. +func TestWindows_SymlinkBypassPrevented(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) + } + + ctx := context.Background() + + // /tmp is outside the user workspace, should be blocked after symlink resolution + // On Windows /tmp resolves to C:\tmp which may not exist, causing different error + result := tool.Execute(ctx, map[string]any{"action": "run", "command": "ls /tmp"}) + if !result.IsError { + t.Errorf("symlink bypass should be blocked: %s", result.ForLLM) + } +} + +// TestWindows_PowerShellEncodingBypass verifies that PowerShell encoding bypass techniques are blocked. +func TestWindows_PowerShellEncodingBypass(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + tool, err := NewExecTool("", false) + require.NoError(t, err) + + ctx := context.Background() + + // Commands using [Text.Encoding] to construct a command string at runtime. + encodingBypassCommands := []string{ + // Basic byte array forms + `[Text.Encoding]::ASCII.GetString([byte[]](0x6c,0x73,0x20,0x7e))`, + `[Text.Encoding]::ASCII.GetString([byte[]](0x69,0x65,0x78))`, + // System.Text.Encoding variant + `[System.Text.Encoding]::ASCII.GetString([byte[]](0x69,0x65,0x78))`, + // With whitespace variation + `[System.Text.Encoding]::ASCII.GetString ([byte[]](0x69,0x65,0x78))`, + // Variable storage form + `$b = [byte[]](0x69,0x65,0x78); [Text.Encoding]::ASCII.GetString($b)`, + // UTF8 variant + `[Text.Encoding]::UTF8.GetString([byte[]](0x69,0x65,0x78))`, + // Unicode variant + `[Text.Encoding]::Unicode.GetString([byte[]](0x69,0x00,0x65,0x00,0x78,0x00))`, + } + + for _, cmd := range encodingBypassCommands { + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + if !result.IsError { + t.Errorf("expected [Text.Encoding] bypass to be blocked: %s", cmd) + } + if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { + t.Errorf("expected 'blocked' message for %s, got: %s", cmd, result.ForLLM) + } + } + + // Commands using PowerShell's -EncodedCommand flag (base64), including short forms. + encodedCommands := []string{ + // Full form + `powershell -NoProfile -NonInteractive -EncodedCommand SQBFAHIAaABlAGwAbAAvAC8A`, + `pwsh -EncodedCommand aWV4`, + // Short forms: -e, -ec, -enc, -en + `pwsh -e SQBFAHIAaABlAGwAbAAvAC8A`, + `pwsh -ec aWV4`, + `pwsh -enc aWV4`, + `pwsh -en aWV4`, + `powershell -e SQBFAHIAaABlAGwAbAAvAC8A`, + `powershell -ec aWV4`, + `powershell -enc aWV4`, + `powershell -en aWV4`, + } + + for _, cmd := range encodedCommands { + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + if !result.IsError { + t.Errorf("expected -EncodedCommand to be blocked: %s", cmd) + } + } + + // Unicode escape sequences that could construct malicious commands + // Using double backslash to represent literal \u in Go string + unicodeCommands := []string{ + `cmd /c "cd %USERPROFILE% \\u0026 dir"`, + `powershell -Command "Write-Host \\u0049EX"`, + `cmd /c "echo \\u0069\\u0065\\u0078"`, + } + + for _, cmd := range unicodeCommands { + result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) + if !result.IsError { + t.Errorf("expected Unicode escape to be blocked: %s", cmd) + } + } +} + func TestShellTool_Background_ReturnsImmediately(t *testing.T) { tool, err := NewExecTool("", false) require.NoError(t, err)