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
}