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:
@@ -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{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user