Enhance bash tool security and improve permission dialog UI

- Expand safe command list with common dev tools (git, go, node, python, etc.)
- Improve multi-word command detection for better security checks
- Add scrollable viewport to permission dialog for better diff viewing
- Fix command batching in TUI update to properly handle multiple commands

🤖 Generated with termai
Co-Authored-By: termai <noreply@termai.io>
This commit is contained in:
Kujtim Hoxha
2025-04-04 15:41:25 +02:00
parent 8f8b403bbc
commit 6419973667
4 changed files with 149 additions and 37 deletions

View File

@@ -38,8 +38,38 @@ var BannedCommands = []string{
} }
var SafeReadOnlyCommands = []string{ var SafeReadOnlyCommands = []string{
// Basic shell commands
"ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis", "ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
"whatis", //... "whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
// Git read-only commands
"git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
"git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
// Go commands
"go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
// Node.js commands
"node", "npm", "npx", "yarn", "pnpm",
// Python commands
"python", "python3", "pip", "pip3", "pytest", "pylint", "mypy", "black", "isort", "flake8", "ruff",
// Docker commands
"docker ps", "docker images", "docker volume", "docker network", "docker info", "docker version",
"docker-compose ps", "docker-compose config",
// Kubernetes commands
"kubectl get", "kubectl describe", "kubectl logs", "kubectl version", "kubectl config",
// Rust commands
"cargo", "rustc", "rustup",
// Java commands
"java", "javac", "mvn", "gradle",
// Misc development tools
"make", "cmake", "bazel", "terraform plan", "terraform validate", "ansible",
} }
func (b *bashTool) Info() ToolInfo { func (b *bashTool) Info() ToolInfo {
@@ -77,19 +107,28 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
return NewTextErrorResponse("missing command"), nil return NewTextErrorResponse("missing command"), nil
} }
// Check for banned commands (first word only)
baseCmd := strings.Fields(params.Command)[0] baseCmd := strings.Fields(params.Command)[0]
for _, banned := range BannedCommands { for _, banned := range BannedCommands {
if strings.EqualFold(baseCmd, banned) { if strings.EqualFold(baseCmd, banned) {
return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
} }
} }
// Check for safe commands (can be multi-word)
isSafeReadOnly := false isSafeReadOnly := false
cmdLower := strings.ToLower(params.Command)
for _, safe := range SafeReadOnlyCommands { for _, safe := range SafeReadOnlyCommands {
if strings.EqualFold(baseCmd, safe) { // Check if command starts with the safe command pattern
if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
// Make sure it's either an exact match or followed by a space or flag
if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
isSafeReadOnly = true isSafeReadOnly = true
break break
} }
} }
}
if !isSafeReadOnly { if !isSafeReadOnly {
p := permission.Default.Request( p := permission.Default.Request(
permission.CreatePermissionRequest{ permission.CreatePermissionRequest{

View File

@@ -119,14 +119,23 @@ func TestBashTool_Run(t *testing.T) {
} }
}) })
t.Run("handles safe read-only commands without permission check", func(t *testing.T) { t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
permission.Default = newMockPermissionService(false) permission.Default = newMockPermissionService(false)
tool := NewBashTool() tool := NewBashTool()
// Test with a safe read-only command // Test with multi-word safe commands
multiWordCommands := []string{
"git status",
"git log -n 5",
"docker ps",
"go test ./...",
"kubectl get pods",
}
for _, cmd := range multiWordCommands {
params := BashParams{ params := BashParams{
Command: "echo 'test'", Command: cmd,
} }
paramsJSON, err := json.Marshal(params) paramsJSON, err := json.Marshal(params)
@@ -139,7 +148,9 @@ func TestBashTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call) response, err := tool.Run(context.Background(), call)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "test\n", response.Content) assert.NotContains(t, response.Content, "permission denied",
"Command %s should be allowed without permission", cmd)
}
}) })
t.Run("handles permission denied", func(t *testing.T) { t.Run("handles permission denied", func(t *testing.T) {

View File

@@ -92,16 +92,7 @@ func formatDiff(diffText string) string {
} }
// Join all formatted lines // Join all formatted lines
content := strings.Join(formattedLines, "\n") return strings.Join(formattedLines, "\n")
// Create a bordered box for the content
contentStyle := lipgloss.NewStyle().
MarginTop(1).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(styles.Flamingo)
return contentStyle.Render(content)
} }
func (p *permissionDialogCmp) Init() tea.Cmd { func (p *permissionDialogCmp) Init() tea.Cmd {
@@ -241,12 +232,46 @@ func (p *permissionDialogCmp) render() string {
headerParts = append(headerParts, keyStyle.Render("Update")) headerParts = append(headerParts, keyStyle.Render("Update"))
// Recreate header content with the updated headerParts // Recreate header content with the updated headerParts
headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...)) headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
// Format the diff with colors instead of using markdown code block
// Format the diff with colors
formattedDiff := formatDiff(pr.Diff) formattedDiff := formatDiff(pr.Diff)
// Set up viewport for the diff content
p.contentViewPort.Width = p.width - 2 - 2
// Calculate content height dynamically based on window size
maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
p.contentViewPort.Height = maxContentHeight
p.contentViewPort.SetContent(formattedDiff)
// Style the viewport
var contentBorder lipgloss.Border
var borderColor lipgloss.TerminalColor
if p.isViewportFocus {
contentBorder = lipgloss.DoubleBorder()
borderColor = styles.Blue
} else {
contentBorder = lipgloss.RoundedBorder()
borderColor = styles.Flamingo
}
contentStyle := lipgloss.NewStyle().
MarginTop(1).
Padding(0, 1).
Border(contentBorder).
BorderForeground(borderColor)
if p.isViewportFocus {
contentStyle = contentStyle.BorderBackground(styles.Surface0)
}
contentFinal := contentStyle.Render(p.contentViewPort.View())
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
lipgloss.Top, lipgloss.Top,
headerContent, headerContent,
formattedDiff, contentFinal,
form, form,
) )
@@ -255,12 +280,46 @@ func (p *permissionDialogCmp) render() string {
headerParts = append(headerParts, keyStyle.Render("Content")) headerParts = append(headerParts, keyStyle.Render("Content"))
// Recreate header content with the updated headerParts // Recreate header content with the updated headerParts
headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...)) headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
// Format the diff with colors instead of using markdown code block
// Format the diff with colors
formattedDiff := formatDiff(pr.Content) formattedDiff := formatDiff(pr.Content)
// Set up viewport for the content
p.contentViewPort.Width = p.width - 2 - 2
// Calculate content height dynamically based on window size
maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
p.contentViewPort.Height = maxContentHeight
p.contentViewPort.SetContent(formattedDiff)
// Style the viewport
var contentBorder lipgloss.Border
var borderColor lipgloss.TerminalColor
if p.isViewportFocus {
contentBorder = lipgloss.DoubleBorder()
borderColor = styles.Blue
} else {
contentBorder = lipgloss.RoundedBorder()
borderColor = styles.Flamingo
}
contentStyle := lipgloss.NewStyle().
MarginTop(1).
Padding(0, 1).
Border(contentBorder).
BorderForeground(borderColor)
if p.isViewportFocus {
contentStyle = contentStyle.BorderBackground(styles.Surface0)
}
contentFinal := contentStyle.Render(p.contentViewPort.View())
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
lipgloss.Top, lipgloss.Top,
headerContent, headerContent,
formattedDiff, contentFinal,
form, form,
) )

View File

@@ -123,8 +123,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.status, _ = a.status.Update(msg) a.status, _ = a.status.Update(msg)
case util.ErrorMsg: case util.ErrorMsg:
a.status, _ = a.status.Update(msg) a.status, _ = a.status.Update(msg)
case util.ClearStatusMsg:
a.status, _ = a.status.Update(msg)
case tea.KeyMsg: case tea.KeyMsg:
if a.editorMode == vimtea.ModeNormal { if a.editorMode == vimtea.ModeNormal {
switch { switch {
@@ -163,16 +161,21 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
} }
var cmds []tea.Cmd
s, cmd := a.status.Update(msg)
a.status = s
cmds = append(cmds, cmd)
if a.dialogVisible { if a.dialogVisible {
d, cmd := a.dialog.Update(msg) d, cmd := a.dialog.Update(msg)
a.dialog = d.(core.DialogCmp) a.dialog = d.(core.DialogCmp)
return a, cmd cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
} }
s, _ := a.status.Update(msg)
a.status = s
p, cmd := a.pages[a.currentPage].Update(msg) p, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = p a.pages[a.currentPage] = p
return a, cmd cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
} }
func (a *appModel) ToggleHelp() { func (a *appModel) ToggleHelp() {