Vectrum
Sign In
116 lines · 3.5 KB · Go
// 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, ", ")
	}
}