// Package approval decides which tool calls need a human. package approval import ( "fmt" "sort" "strings" "mvdan.cc/sh/v3/syntax" ) // ShellInfo is what static analysis can honestly say about a shell command. type ShellInfo struct { // Commands is every command name the parser could resolve statically. Commands []string // Opaque is true when the script contains constructs whose effect cannot // be determined from the text: command substitution, eval, an interpreter // invoked with -c, or a variable in command position. Opaque bool // Reason explains Opaque, for the approval prompt. Reason string // ParseError is set when the script is not valid shell. ParseError error } // interpreters run whatever string they are handed, so a -c argument makes the // rest of the analysis meaningless. var interpreters = map[string]bool{ "bash": true, "sh": true, "zsh": true, "dash": true, "ksh": true, "python": true, "python3": true, "perl": true, "ruby": true, "node": true, "env": true, "eval": true, "exec": true, "xargs": true, "find": true, } // Analyze parses a shell command and reports what it will run. // // This exists to make the approval prompt specific, not to be a security // boundary. Allowlisting shell commands by matching strings does not work — // "git status; rm -rf ~" defeats it, and so do backticks, $(), &&, and any // interpreter with -c. Where this cannot be certain it says so, and the // decision goes to a human or to the sandbox. func Analyze(script string) ShellInfo { var info ShellInfo f, err := syntax.NewParser().Parse(strings.NewReader(script), "") if err != nil { return ShellInfo{ParseError: err, Opaque: true, Reason: "the command is not valid shell"} } seen := map[string]bool{} syntax.Walk(f, func(node syntax.Node) bool { switch n := node.(type) { case *syntax.CmdSubst: info.Opaque, info.Reason = true, "it contains command substitution, so what runs is decided at run time" case *syntax.CallExpr: if len(n.Args) == 0 { return true } name, ok := literal(n.Args[0]) if !ok { info.Opaque, info.Reason = true, "a command name is built from a variable" return true } seen[name] = true if interpreters[name] && hasInlineScript(n.Args[1:]) { info.Opaque = true info.Reason = fmt.Sprintf("it runs %s with an inline script, which can do anything", name) } } return true }) for name := range seen { info.Commands = append(info.Commands, name) } sort.Strings(info.Commands) return info } // literal returns a word's text when it is a plain literal, and reports false // when any part of it is expanded at run time. func literal(w *syntax.Word) (string, bool) { if len(w.Parts) != 1 { return "", false } lit, ok := w.Parts[0].(*syntax.Lit) if !ok { return "", false } return lit.Value, true } // hasInlineScript reports whether the arguments carry a script to execute, as // opposed to a file to read. func hasInlineScript(args []*syntax.Word) bool { for _, a := range args { if s, ok := literal(a); ok && (s == "-c" || s == "-e" || s == "--command") { return true } } return false } // Describe renders the analysis for a prompt. func (s ShellInfo) Describe() string { switch { case s.ParseError != nil: return "cannot be analysed: " + s.Reason case s.Opaque && len(s.Commands) > 0: return fmt.Sprintf("runs %s, and %s", strings.Join(s.Commands, ", "), s.Reason) case s.Opaque: return s.Reason case len(s.Commands) == 0: return "runs nothing" default: return "runs " + strings.Join(s.Commands, ", ") } }