@@ -10,6 +10,7 @@ import (
11 "github.com/charmbracelet/crush/internal/permission"
11 "github.com/charmbracelet/crush/internal/permission"
12 "github.com/charmbracelet/crush/internal/shell"
12 "github.com/charmbracelet/crush/internal/shell"
13+ "mvdan.cc/sh/v3/syntax"
15type BashParams struct {
16type BashParams struct {
@@ -40,10 +41,235 @@ const (
40 BashNoOutput = "no output"
41 BashNoOutput = "no output"
44+func containsBannedCommand(node syntax.Node) bool {
49+ switch n := node.(type) {
50+ case *syntax.CallExpr:
52+ cmdName := getWordValue(n.Args[0])
53+ for _, banned := range bannedCommands {
54+ if strings.EqualFold(cmdName, banned) {
59+ for _, arg := range n.Args {
60+ if containsBannedCommand(arg) {
65+ if checkWordForBannedCommands(n) {
68+ for _, part := range n.Parts {
69+ if containsBannedCommand(part) {
73+ case *syntax.CmdSubst:
74+ for _, stmt := range n.Stmts {
75+ if containsBannedCommand(stmt) {
79+ case *syntax.Subshell:
80+ for _, stmt := range n.Stmts {
81+ if containsBannedCommand(stmt) {
86+ if containsBannedCommand(n.Cmd) {
89+ for _, redir := range n.Redirs {
90+ if containsBannedCommand(redir) {
94+ case *syntax.BinaryCmd:
95+ return containsBannedCommand(n.X) || containsBannedCommand(n.Y)
97+ for _, stmt := range n.Stmts {
98+ if containsBannedCommand(stmt) {
102+ case *syntax.IfClause:
103+ for _, stmt := range n.Cond {
104+ if containsBannedCommand(stmt) {
108+ for _, stmt := range n.Then {
109+ if containsBannedCommand(stmt) {
113+ if n.Else != nil && containsBannedCommand(n.Else) {
116+ case *syntax.WhileClause:
117+ for _, stmt := range n.Cond {
118+ if containsBannedCommand(stmt) {
122+ for _, stmt := range n.Do {
123+ if containsBannedCommand(stmt) {
127+ case *syntax.ForClause:
128+ for _, stmt := range n.Do {
129+ if containsBannedCommand(stmt) {
133+ if containsBannedCommand(n.Loop) {
136+ case *syntax.CaseClause:
137+ for _, item := range n.Items {
138+ for _, stmt := range item.Stmts {
139+ if containsBannedCommand(stmt) {
144+ case *syntax.FuncDecl:
145+ return containsBannedCommand(n.Body)
146+ case *syntax.ArithmExp:
147+ return containsBannedCommand(n.X)
148+ case *syntax.Redirect:
149+ return containsBannedCommand(n.Word)
154+func checkWordForBannedCommands(word *syntax.Word) bool {
159+ for _, part := range word.Parts {
160+ switch p := part.(type) {
161+ case *syntax.SglQuoted:
162+ if checkQuotedStringForBannedCommands(p.Value) {
165+ case *syntax.DblQuoted:
166+ var content strings.Builder
167+ for _, qpart := range p.Parts {
168+ if lit, ok := qpart.(*syntax.Lit); ok {
169+ content.WriteString(lit.Value)
172+ if checkQuotedStringForBannedCommands(content.String()) {
180+func checkQuotedStringForBannedCommands(content string) bool {
181+ parser := syntax.NewParser()
182+ file, err := parser.Parse(strings.NewReader(content), "")
187+ if len(file.Stmts) == 0 {
191+ // Simple heuristic: if it looks like prose rather than commands, don't flag it
192+ if len(file.Stmts) == 1 {
193+ stmt := file.Stmts[0]
194+ if callExpr, ok := stmt.Cmd.(*syntax.CallExpr); ok {
195+ if len(callExpr.Args) > 2 {
197+ for i, arg := range callExpr.Args {
201+ argStr := getWordValue(arg)
202+ if strings.HasPrefix(argStr, "-") {
214+ for _, stmt := range file.Stmts {
215+ if containsBannedCommand(stmt) {
222+func getWordValue(word *syntax.Word) string {
223+ if word == nil || len(word.Parts) == 0 {
227+ var result strings.Builder
228+ for _, part := range word.Parts {
229+ switch p := part.(type) {
231+ result.WriteString(p.Value)
232+ case *syntax.SglQuoted:
233+ result.WriteString(p.Value)
234+ case *syntax.DblQuoted:
235+ for _, qpart := range p.Parts {
236+ if lit, ok := qpart.(*syntax.Lit); ok {
237+ result.WriteString(lit.Value)
242+ return result.String()
245+func validateCommand(command string) error {
246+ parser := syntax.NewParser()
247+ file, err := parser.Parse(strings.NewReader(command), "")
249+ parts := strings.Fields(command)
252+ for _, banned := range bannedCommands {
253+ if strings.EqualFold(baseCmd, banned) {
254+ return fmt.Errorf("command '%s' is not allowed", baseCmd)
261+ for _, stmt := range file.Stmts {
262+ if containsBannedCommand(stmt) {
263+ return fmt.Errorf("command contains banned operations")
43var bannedCommands = []string{
269var bannedCommands = []string{
44 "alias", "curl", "curlie", "wget", "axel", "aria2c",
270 "alias", "curl", "curlie", "wget", "axel", "aria2c",
45 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
271 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
46− "http-prompt", "chrome", "firefox", "safari",
272+ "http-prompt", "chrome", "firefox", "safari", "sudo",
49// getSafeReadOnlyCommands returns platform-appropriate safe commands
275// getSafeReadOnlyCommands returns platform-appropriate safe commands
@@ -289,11 +515,9 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
289 return NewTextErrorResponse("missing command"), nil
515 return NewTextErrorResponse("missing command"), nil
292− baseCmd := strings.Fields(params.Command)[0]
518+
293− for _, banned := range bannedCommands {
519+ if err := validateCommand(params.Command); err != nil {
294− if strings.EqualFold(baseCmd, banned) {
295− return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
520+ return NewTextErrorResponse(err.Error()), nil
299 isSafeReadOnly := false
523 isSafeReadOnly := false