// 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, ", ")
}
}