@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/charmbracelet/crush/internal/permission"
1212 "github.com/charmbracelet/crush/internal/shell"
13+ "mvdan.cc/sh/v3/syntax"
1314)
1415
1516type BashParams struct {
@@ -40,10 +41,235 @@ const (
4041 BashNoOutput = "no output"
4142)
4243
44+func containsBannedCommand(node syntax.Node) bool {
45+ if node == nil {
46+ return false
47+ }
48+
49+ switch n := node.(type) {
50+ case *syntax.CallExpr:
51+ if len(n.Args) > 0 {
52+ cmdName := getWordValue(n.Args[0])
53+ for _, banned := range bannedCommands {
54+ if strings.EqualFold(cmdName, banned) {
55+ return true
56+ }
57+ }
58+ }
59+ for _, arg := range n.Args {
60+ if containsBannedCommand(arg) {
61+ return true
62+ }
63+ }
64+ case *syntax.Word:
65+ if checkWordForBannedCommands(n) {
66+ return true
67+ }
68+ for _, part := range n.Parts {
69+ if containsBannedCommand(part) {
70+ return true
71+ }
72+ }
73+ case *syntax.CmdSubst:
74+ for _, stmt := range n.Stmts {
75+ if containsBannedCommand(stmt) {
76+ return true
77+ }
78+ }
79+ case *syntax.Subshell:
80+ for _, stmt := range n.Stmts {
81+ if containsBannedCommand(stmt) {
82+ return true
83+ }
84+ }
85+ case *syntax.Stmt:
86+ if containsBannedCommand(n.Cmd) {
87+ return true
88+ }
89+ for _, redir := range n.Redirs {
90+ if containsBannedCommand(redir) {
91+ return true
92+ }
93+ }
94+ case *syntax.BinaryCmd:
95+ return containsBannedCommand(n.X) || containsBannedCommand(n.Y)
96+ case *syntax.Block:
97+ for _, stmt := range n.Stmts {
98+ if containsBannedCommand(stmt) {
99+ return true
100+ }
101+ }
102+ case *syntax.IfClause:
103+ for _, stmt := range n.Cond {
104+ if containsBannedCommand(stmt) {
105+ return true
106+ }
107+ }
108+ for _, stmt := range n.Then {
109+ if containsBannedCommand(stmt) {
110+ return true
111+ }
112+ }
113+ if n.Else != nil && containsBannedCommand(n.Else) {
114+ return true
115+ }
116+ case *syntax.WhileClause:
117+ for _, stmt := range n.Cond {
118+ if containsBannedCommand(stmt) {
119+ return true
120+ }
121+ }
122+ for _, stmt := range n.Do {
123+ if containsBannedCommand(stmt) {
124+ return true
125+ }
126+ }
127+ case *syntax.ForClause:
128+ for _, stmt := range n.Do {
129+ if containsBannedCommand(stmt) {
130+ return true
131+ }
132+ }
133+ if containsBannedCommand(n.Loop) {
134+ return true
135+ }
136+ case *syntax.CaseClause:
137+ for _, item := range n.Items {
138+ for _, stmt := range item.Stmts {
139+ if containsBannedCommand(stmt) {
140+ return true
141+ }
142+ }
143+ }
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)
150+ }
151+ return false
152+}
153+
154+func checkWordForBannedCommands(word *syntax.Word) bool {
155+ if word == nil {
156+ return false
157+ }
158+
159+ for _, part := range word.Parts {
160+ switch p := part.(type) {
161+ case *syntax.SglQuoted:
162+ if checkQuotedStringForBannedCommands(p.Value) {
163+ return true
164+ }
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)
170+ }
171+ }
172+ if checkQuotedStringForBannedCommands(content.String()) {
173+ return true
174+ }
175+ }
176+ }
177+ return false
178+}
179+
180+func checkQuotedStringForBannedCommands(content string) bool {
181+ parser := syntax.NewParser()
182+ file, err := parser.Parse(strings.NewReader(content), "")
183+ if err != nil {
184+ return false
185+ }
186+
187+ if len(file.Stmts) == 0 {
188+ return false
189+ }
190+
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 {
196+ allText := true
197+ for i, arg := range callExpr.Args {
198+ if i == 0 {
199+ continue
200+ }
201+ argStr := getWordValue(arg)
202+ if strings.HasPrefix(argStr, "-") {
203+ allText = false
204+ break
205+ }
206+ }
207+ if allText {
208+ return false
209+ }
210+ }
211+ }
212+ }
213+
214+ for _, stmt := range file.Stmts {
215+ if containsBannedCommand(stmt) {
216+ return true
217+ }
218+ }
219+ return false
220+}
221+
222+func getWordValue(word *syntax.Word) string {
223+ if word == nil || len(word.Parts) == 0 {
224+ return ""
225+ }
226+
227+ var result strings.Builder
228+ for _, part := range word.Parts {
229+ switch p := part.(type) {
230+ case *syntax.Lit:
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)
238+ }
239+ }
240+ }
241+ }
242+ return result.String()
243+}
244+
245+func validateCommand(command string) error {
246+ parser := syntax.NewParser()
247+ file, err := parser.Parse(strings.NewReader(command), "")
248+ if err != nil {
249+ parts := strings.Fields(command)
250+ if len(parts) > 0 {
251+ baseCmd := parts[0]
252+ for _, banned := range bannedCommands {
253+ if strings.EqualFold(baseCmd, banned) {
254+ return fmt.Errorf("command '%s' is not allowed", baseCmd)
255+ }
256+ }
257+ }
258+ return nil
259+ }
260+
261+ for _, stmt := range file.Stmts {
262+ if containsBannedCommand(stmt) {
263+ return fmt.Errorf("command contains banned operations")
264+ }
265+ }
266+ return nil
267+}
268+
43269var bannedCommands = []string{
44270 "alias", "curl", "curlie", "wget", "axel", "aria2c",
45271 "nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
46− "http-prompt", "chrome", "firefox", "safari",
272+ "http-prompt", "chrome", "firefox", "safari", "sudo",
47273}
48274
49275// getSafeReadOnlyCommands returns platform-appropriate safe commands
@@ -289,11 +515,9 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
289515 return NewTextErrorResponse("missing command"), nil
290516 }
291517
292− baseCmd := strings.Fields(params.Command)[0]
293− for _, banned := range bannedCommands {
294− if strings.EqualFold(baseCmd, banned) {
295− return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
296− }
518+
519+ if err := validateCommand(params.Command); err != nil {
520+ return NewTextErrorResponse(err.Error()), nil
297521 }
298522
299523 isSafeReadOnly := false