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 = "" } 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 }