Vectrum
Sign In
158 lines · 4.9 KB · Go
package approval

import (
	"strings"

	"mvdan.cc/sh/v3/syntax"
)

// SimpleCommand is one command a shell script runs, as its words.
//
// It exists for rules written as command prefixes — Claude Code's
// Bash(npm run test:*) — which can only be matched honestly one simple command
// at a time. Matched against the whole line, "npm run test && rm -rf ~" starts
// with "npm run test", and an allow rule written for the first half would
// quietly allow the second.
type SimpleCommand struct {
	// Words are the command's words after quote removal: "git", "commit",
	// "-m", "fix it". A word the shell would build at run time — a variable,
	// a glob, a backslash escape this reader does not undo — is left empty,
	// and Literal says so.
	Words []string
	// Literal is parallel to Words: true where the word is fixed text.
	Literal []bool
	// Pinned is false when something about this command is decided by more
	// than its words: a variable assignment in front of it, which can change
	// what the name resolves to (PATH=. git), or an output redirection to a
	// file, which writes somewhere no prefix of the words mentions.
	Pinned bool
}

// Text is the command as words joined by single spaces, with a placeholder
// where a word is not fixed. It is for messages, not for matching.
func (c SimpleCommand) Text() string {
	out := make([]string, len(c.Words))
	for i, w := range c.Words {
		if !c.Literal[i] {
			w = "<expanded>"
		}
		out[i] = w
	}
	return strings.Join(out, " ")
}

// SimpleCommands splits a script into every simple command it contains,
// however they are joined: ;, &&, ||, pipes, subshells, the bodies of loops,
// conditionals and functions. The ShellInfo is Analyze's for the same script;
// when it is opaque, the list is what could be seen and not necessarily
// everything that will run, so a caller deciding to allow something must
// treat an opaque script as matching nothing.
func SimpleCommands(script string) ([]SimpleCommand, ShellInfo) {
	info := Analyze(script)
	if info.ParseError != nil {
		return nil, info
	}
	f, err := syntax.NewParser().Parse(strings.NewReader(script), "")
	if err != nil {
		return nil, info
	}
	var out []SimpleCommand
	syntax.Walk(f, func(node syntax.Node) bool {
		switch n := node.(type) {
		case *syntax.ProcSubst:
			// <(cmd) runs cmd as surely as $(cmd) does, and Analyze does not
			// look for it, so it is marked here.
			info.Opaque, info.Reason = true, "it contains process substitution, so what runs is decided at run time"
		case *syntax.Stmt:
			call, ok := n.Cmd.(*syntax.CallExpr)
			if ok && len(call.Args) > 0 {
				out = append(out, simple(call, n.Redirs))
				return true
			}
			if decl, ok := n.Cmd.(*syntax.DeclClause); ok {
				// export, local, declare: a builtin with assignments for
				// arguments. Kept as a command so a rule must account for it,
				// never pinned, because an assignment is what it does.
				out = append(out, SimpleCommand{Words: []string{decl.Variant.Value}, Literal: []bool{true}})
			}
		}
		return true
	})
	return out, info
}

func simple(call *syntax.CallExpr, redirs []*syntax.Redirect) SimpleCommand {
	c := SimpleCommand{Pinned: len(call.Assigns) == 0}
	for _, w := range call.Args {
		text, ok := unquote(w)
		c.Words = append(c.Words, text)
		c.Literal = append(c.Literal, ok)
	}
	for _, r := range redirs {
		if writesFile(r) {
			c.Pinned = false
		}
	}
	return c
}

// unquote returns a word's text after quote removal when the text is fixed,
// and false when any part of it is expanded at run time.
func unquote(w *syntax.Word) (string, bool) {
	var b strings.Builder
	for _, part := range w.Parts {
		switch p := part.(type) {
		case *syntax.Lit:
			// A backslash in an unquoted literal is an escape the shell will
			// remove; rather than reimplement that, the word is not fixed.
			// Globs and a leading ~ are expanded by the shell, so they are
			// not fixed either.
			if strings.ContainsAny(p.Value, "\\*?[") || (b.Len() == 0 && strings.HasPrefix(p.Value, "~")) {
				return "", false
			}
			b.WriteString(p.Value)
		case *syntax.SglQuoted:
			if p.Dollar {
				return "", false
			}
			b.WriteString(p.Value)
		case *syntax.DblQuoted:
			for _, inner := range p.Parts {
				lit, ok := inner.(*syntax.Lit)
				if !ok || strings.Contains(lit.Value, "\\") {
					return "", false
				}
				b.WriteString(lit.Value)
			}
		default:
			return "", false
		}
	}
	return b.String(), true
}

// writesFile reports whether a redirection writes to a file. Duplicating a
// descriptor (2>&1) and throwing output away (>/dev/null) do not.
func writesFile(r *syntax.Redirect) bool {
	switch r.Op {
	case syntax.RdrIn, syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc, syntax.DplIn:
		return false
	case syntax.DplOut:
		target, ok := unquote(r.Word)
		return !ok || !(target == "-" || isDigits(target))
	}
	target, ok := unquote(r.Word)
	return !ok || target != "/dev/null"
}

func isDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}