package shell import ( "context" "fmt" "io" "strings" "mvdan.cc/sh/moreinterp/coreutils" "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/interp" "mvdan.cc/sh/v3/syntax" ) // RunOptions configures a single stateless shell execution via [Run]. // // The zero value is not useful; at minimum Command must be set. Stdin, // Stdout, and Stderr may be nil (nil readers/writers are treated as // empty/discard). BlockFuncs may be nil to disable block-list enforcement — // hooks use this to run user-authored commands with the same trust level as // a shell alias. type RunOptions struct { // Command is the shell source to parse and execute. Command string // Cwd is the working directory for the execution. Required: callers // must supply a non-empty value. Run does not silently fall back to // the Crush process cwd — hooks and the bash tool have different // notions of "default" and each owns that decision. Cwd string // Env is the full environment visible to the command. The caller is // responsible for inheriting from os.Environ() if that's desired. Env []string // Stdin is the command's standard input. nil is equivalent to an empty // input stream. Stdin io.Reader // Stdout receives the command's standard output. nil discards output. Stdout io.Writer // Stderr receives the command's standard error. nil discards output. Stderr io.Writer // BlockFuncs is an optional list of deny-list matchers applied before // each command reaches the exec layer. nil disables blocking entirely. BlockFuncs []BlockFunc } // Run parses and executes a shell command using the same mvdan.cc/sh // interpreter stack that the stateful [Shell] type uses (builtins, // optional block list, optional Go coreutils). It is safe to call // concurrently from multiple goroutines: each call builds its own // [interp.Runner] and shares no state with other callers or with any // [Shell] instance. // // Errors returned from the command itself (non-zero exit, context // cancellation, parse failures) follow the same conventions as // [Shell.Exec]: inspect with [IsInterrupt] and [ExitCode]. func Run(ctx context.Context, opts RunOptions) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("command execution panic: %v", r) } }() if opts.Cwd == "" { return fmt.Errorf("shell.Run: Cwd is required") } stdout := opts.Stdout if stdout == nil { stdout = io.Discard } stderr := opts.Stderr if stderr == nil { stderr = io.Discard } line, err := syntax.NewParser().Parse(strings.NewReader(opts.Command), "") if err != nil { return fmt.Errorf("could not parse command: %w", err) } runner, err := newRunner(opts.Cwd, opts.Env, opts.Stdin, stdout, stderr, opts.BlockFuncs) if err != nil { return fmt.Errorf("could not run command: %w", err) } return runner.Run(ctx, line) } // newRunner constructs an [interp.Runner] configured with the standard // Crush handler stack. Shared by the stateless [Run] entrypoint and the // stateful [Shell] so the two surfaces cannot drift. func newRunner(cwd string, env []string, stdin io.Reader, stdout, stderr io.Writer, blockFuncs []BlockFunc) (*interp.Runner, error) { return interp.New( interp.StdIO(stdin, stdout, stderr), interp.Interactive(false), interp.Env(expand.ListEnviron(env...)), interp.Dir(cwd), interp.ExecHandlers(standardHandlers(blockFuncs)...), ) } // standardHandlers returns the exec-handler middleware chain used by both // [Run] and [Shell]. Order matters: // 1. builtins first (so Crush's in-process jq wins over any PATH binary); // 2. script dispatch (shebang / binary / shell-source for path-prefixed // argv[0], no-op for bare commands) — runs before the block list so // that deny rules see the already-resolved argv of anything the // script exec's rather than the outer path-prefixed wrapper; // 3. block list; // 4. optional Go coreutils (only when useGoCoreUtils is on); // 5. process-group exec (terminal handler). This replaces mvdan's // DefaultExecHandler so every external command gets its own process // group and is killed as a tree on ctx cancellation. func standardHandlers(blockFuncs []BlockFunc) []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{ builtinHandler(), scriptDispatchHandler(blockFuncs), blockHandler(blockFuncs), } if useGoCoreUtils { handlers = append(handlers, coreutils.ExecHandler) } handlers = append(handlers, processGroupExecHandler()) return handlers } // builtinHandler returns middleware that dispatches recognized Crush // builtins to their in-process Go implementations. Currently: jq. func builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { return func(ctx context.Context, args []string) error { if len(args) == 0 { return next(ctx, args) } switch args[0] { case "jq": hc := interp.HandlerCtx(ctx) return handleJQ(ctx, args, hc.Stdin, hc.Stdout, hc.Stderr) default: return next(ctx, args) } } } } // blockHandler returns middleware that rejects commands matched by any of // the provided [BlockFunc]s before they reach the underlying exec path. // A nil or empty blockFuncs slice is a no-op. func blockHandler(blockFuncs []BlockFunc) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { return func(ctx context.Context, args []string) error { if len(args) == 0 { return next(ctx, args) } for _, blockFunc := range blockFuncs { if blockFunc(args) { return fmt.Errorf("command is not allowed for security reasons: %q", args[0]) } } return next(ctx, args) } } }