From e7c1634802bc773e76a72ec7bb9b9c6b228b1440 Mon Sep 17 00:00:00 2001 From: wuchulonly <174486414+wuchulonly@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:29:45 +0100 Subject: [PATCH] fix(commands): route bash scripts using shell syntax --- go.mod | 2 +- pkg/commands/bash.go | 166 +++++++------------------ pkg/commands/bash_syntax.go | 126 +++++++++++++++++++ pkg/commands/bash_syntax_test.go | 203 +++++++++++++++++++++++++++++++ pkg/commands/command.go | 50 +------- pkg/commands/registry.go | 8 +- 6 files changed, 381 insertions(+), 174 deletions(-) create mode 100644 pkg/commands/bash_syntax.go create mode 100644 pkg/commands/bash_syntax_test.go diff --git a/go.mod b/go.mod index 8d4d4cec1..5b348bf0c 100644 --- a/go.mod +++ b/go.mod @@ -346,7 +346,7 @@ require ( modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - mvdan.cc/sh/v3 v3.13.1 // indirect + mvdan.cc/sh/v3 v3.13.1 ) replace github.com/wasilibs/go-re2 => github.com/chainreactors/go-re2 v1.11.1-0.20260803043001-2e8338def4c6 diff --git a/pkg/commands/bash.go b/pkg/commands/bash.go index ae5ec3c06..8a2a2e8df 100644 --- a/pkg/commands/bash.go +++ b/pkg/commands/bash.go @@ -269,8 +269,8 @@ func (t *BashTool) Execute(ctx context.Context, arguments string) (*coretool.Res return nil, err } - command := strings.TrimSpace(args.Command) - if command == "" { + command := args.Command + if strings.TrimSpace(command) == "" { return nil, fmt.Errorf("empty command") } if isOnlyCommentsOrBlank(command) { @@ -302,8 +302,7 @@ func (t *BashTool) Execute(ctx context.Context, arguments string) (*coretool.Res // final session state. Non-zero exits are represented by Info.ExitCode rather // than returned as transport errors. func (t *BashTool) RunForeground(ctx context.Context, command string, options BashExecOptions) (*Execution, error) { - command = strings.TrimSpace(command) - if command == "" { + if strings.TrimSpace(command) == "" { return nil, fmt.Errorf("empty command") } if options.WorkDir == "" { @@ -388,8 +387,11 @@ func (t *BashTool) RunForegroundTool(ctx context.Context, command string, option // Start resolves command through the built-in registry or the system shell and // always returns an Execution backed by one PTY session. func (t *BashTool) start(ctx context.Context, command string, options BashExecOptions) (*Execution, error) { - command = stripCommentsAndBlanks(command) - if strings.TrimSpace(command) == "" { + script, err := parseShellCommand(command) + if err != nil { + return nil, fmt.Errorf("parse shell command: %w", err) + } + if len(script.Stmts) == 0 { return nil, fmt.Errorf("empty command") } if ctx == nil { @@ -409,17 +411,8 @@ func (t *BashTool) start(ctx context.Context, command string, options BashExecOp if workDir == "" { workDir = t.workDir } - left, right, hasPipe := splitPipeline(command) - leftToken := firstCommandToken(left) - if !hasPipe { - if cmd, ok := t.resolve(leftToken); ok { - if tokens, err := SplitCommandLine(left); err == nil { - if args, syntaxErr := stripShellSyntax(tokens[1:]); syntaxErr == nil { - args = normalizeNoColor(cmd.Name, args) - return t.startBuiltin(ctx, cmd, args, timeout, workDir, t.runEnv(ctx, options.Env, nil, ""), options) - } - } - } + if cmd, args, ok := t.literalBuiltin(script); ok { + return t.startBuiltin(ctx, cmd, args, timeout, workDir, t.runEnv(ctx, options.Env, nil, ""), options) } adapter, err := t.ensureShellCommands() if err != nil { @@ -448,64 +441,49 @@ func (t *BashTool) start(ctx context.Context, command string, options BashExecOp t.releaseProcess(cleanup, execution) return execution, nil } - env := t.runEnv(ctx, options.Env, nil, "") - if cmd, ok := t.resolve(leftToken); ok { - tokens, err := SplitCommandLine(left) - if err != nil { - return nil, err - } - args, err := stripShellSyntax(tokens[1:]) - if err != nil { - return nil, err - } - args = normalizeNoColor(cmd.Name, args) - if hasPipe && right != "" { - options, cleanup, err := t.prepareShell(command, options) - if err != nil { - return nil, err + left, right, hasPipe := splitPipeline(script, command) + if hasPipe { + leftScript, leftErr := parseShellCommand(left) + rightScript, rightErr := parseShellCommand(right) + if leftErr == nil && rightErr == nil { + if cmd, args, ok := t.literalBuiltin(leftScript); ok && !t.hasRegisteredCommand(rightScript) { + options, cleanup, err := t.prepareShell(command, options) + if err != nil { + return nil, err + } + env := t.runEnv(ctx, options.Env, nil, "") + execution, err := t.startBuiltinToShell(ctx, cmd, args, right, timeout, workDir, env, options) + if err != nil { + cleanup() + return nil, err + } + t.releaseProcess(cleanup, execution) + return execution, nil } - env = t.runEnv(ctx, options.Env, nil, "") - execution, err := t.startBuiltinToShell(ctx, cmd, args, right, timeout, workDir, env, options) - if err != nil { - cleanup() - return nil, err + if cmd, args, ok := t.literalBuiltin(rightScript); ok && !t.hasRegisteredCommand(leftScript) { + options, cleanup, err := t.prepareShell(command, options) + if err != nil { + return nil, err + } + env := t.runEnv(ctx, options.Env, nil, "") + execution, err := t.startShellToBuiltin(ctx, left, cmd, args, timeout, workDir, env, options) + if err != nil { + cleanup() + return nil, err + } + t.releaseProcess(cleanup, execution) + return execution, nil } - t.releaseProcess(cleanup, execution) - return execution, nil } - return t.startBuiltin(ctx, cmd, args, timeout, workDir, env, options) } - if hasPipe && right != "" { - rightToken := firstCommandToken(right) - if cmd, ok := t.resolve(rightToken); ok { - tokens, err := SplitCommandLine(right) - if err != nil { - return nil, err - } - args, err := stripShellSyntax(tokens[1:]) - if err != nil { - return nil, err - } - args = normalizeNoColor(cmd.Name, args) - options, cleanup, err := t.prepareShell(command, options) - if err != nil { - return nil, err - } - env = t.runEnv(ctx, options.Env, nil, "") - execution, err := t.startShellToBuiltin(ctx, left, cmd, args, timeout, workDir, env, options) - if err != nil { - cleanup() - return nil, err - } - t.releaseProcess(cleanup, execution) - return execution, nil - } + if t.hasRegisteredCommand(script) { + return nil, fmt.Errorf("registered commands with shell pipes, command chaining, file redirection or expansion require EnableShellCommands") } options, cleanup, err := t.prepareShell(command, options) if err != nil { return nil, err } - env = t.runEnv(ctx, options.Env, nil, "") + env := t.runEnv(ctx, options.Env, nil, "") execution := newExecution(t.tasks, command, nil, workDir, env) info, err := t.tasks.Create(workDir, command, options.Name, timeout, env, "") if err != nil { @@ -859,62 +837,6 @@ func isOnlyCommentsOrBlank(cmdLine string) bool { return true } -func stripCommentsAndBlanks(input string) string { - lines := strings.Split(input, "\n") - kept := make([]string, 0, len(lines)) - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - kept = append(kept, line) - } - return strings.Join(kept, "\n") -} - -func firstCommandToken(input string) string { - tokens, err := SplitCommandLine(input) - if err != nil || len(tokens) == 0 { - return "" - } - return tokens[0] -} - -func splitPipeline(commandLine string) (left, right string, ok bool) { - var quote rune - escaped := false - runes := []rune(commandLine) - for i := 0; i < len(runes); i++ { - r := runes[i] - if escaped { - escaped = false - continue - } - if r == '\\' { - escaped = true - continue - } - if quote != 0 { - if r == quote { - quote = 0 - } - continue - } - if r == '\'' || r == '"' { - quote = r - continue - } - if r == '|' { - if i+1 < len(runes) && runes[i+1] == '|' { - i++ - continue - } - return strings.TrimSpace(string(runes[:i])), strings.TrimSpace(string(runes[i+1:])), true - } - } - return commandLine, "", false -} - // WithEnvironment sets the composition's child-process environment. Call only // during construction; per-invocation overrides remain owned by the caller. func (t *BashTool) WithEnvironment(values map[string]string) *BashTool { diff --git a/pkg/commands/bash_syntax.go b/pkg/commands/bash_syntax.go new file mode 100644 index 000000000..a01f145b3 --- /dev/null +++ b/pkg/commands/bash_syntax.go @@ -0,0 +1,126 @@ +package commands + +import ( + "strings" + + "github.com/chainreactors/aiscan/pkg/types" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +func parseShellCommand(command string) (*syntax.File, error) { + return syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "") +} + +// literalBuiltin proves that bypassing the shell preserves the command's +// meaning. Never infer shell syntax from argv: quoting has already been lost. +func (t *BashTool) literalBuiltin(script *syntax.File) (*types.CommandSpec, []string, bool) { + if len(script.Stmts) != 1 || !plainStatement(script.Stmts[0]) { + return nil, nil, false + } + call, ok := script.Stmts[0].Cmd.(*syntax.CallExpr) + if !ok || len(call.Assigns) != 0 || len(call.Args) == 0 { + return nil, nil, false + } + for _, word := range call.Args { + if !literalParts(word.Parts, false) { + return nil, nil, false + } + } + // Use a fresh configuration for concurrent calls. The AST check excludes + // environment, filesystem and command expansion; Fields only removes quotes + // and escapes, including quoted empty arguments. + argv, err := expand.Fields(&expand.Config{}, call.Args...) + if err != nil || len(argv) == 0 { + return nil, nil, false + } + command, ok := t.resolve(argv[0]) + if !ok { + return nil, nil, false + } + return command, normalizeNoColor(command.Name, argv[1:]), true +} + +func plainStatement(stmt *syntax.Stmt) bool { + return !stmt.Negated && !stmt.Background && !stmt.Coprocess && !stmt.Disown && len(stmt.Redirs) == 0 +} + +func literalParts(parts []syntax.WordPart, quoted bool) bool { + for _, part := range parts { + switch part := part.(type) { + case *syntax.Lit: + if !quoted { + for i := 0; i < len(part.Value); i++ { + if part.Value[i] == '\\' { + i++ + } else if strings.ContainsRune("*?[{~", rune(part.Value[i])) { + return false + } + } + } + case *syntax.SglQuoted: + if part.Dollar { + return false + } + case *syntax.DblQuoted: + if part.Dollar || !literalParts(part.Parts, true) { + return false + } + default: + return false + } + } + return true +} + +// splitPipeline retains the legacy native-to-shell bridge only for an actual +// foreground pipeline. Operator positions refer to the original script, so +// quotes and nested shell constructs cannot be mistaken for the separator. +func splitPipeline(script *syntax.File, command string) (left, right string, ok bool) { + if len(script.Stmts) != 1 || !plainStatement(script.Stmts[0]) { + return "", "", false + } + pipe, ok := script.Stmts[0].Cmd.(*syntax.BinaryCmd) + if !ok || pipe.Op != syntax.Pipe { + return "", "", false + } + // A heredoc body can follow the pipe on later lines. Only the full shell + // adapter may execute that script; slicing at the pipe would move its body. + hasHeredoc := false + syntax.Walk(script, func(node syntax.Node) bool { + if redir, ok := node.(*syntax.Redirect); ok && redir.Hdoc != nil { + hasHeredoc = true + } + return !hasHeredoc + }) + if hasHeredoc { + return "", "", false + } + for plainStatement(pipe.X) { + nested, ok := pipe.X.Cmd.(*syntax.BinaryCmd) + if !ok || nested.Op != syntax.Pipe { + break + } + pipe = nested + } + offset := int(pipe.OpPos.Offset()) + return command[:offset], command[offset+1:], true +} + +func (t *BashTool) hasRegisteredCommand(script *syntax.File) bool { + found := false + syntax.Walk(script, func(node syntax.Node) bool { + if found { + return false + } + call, ok := node.(*syntax.CallExpr) + if ok && len(call.Args) > 0 && literalParts(call.Args[0].Parts, false) { + argv, err := expand.Fields(&expand.Config{}, call.Args[0]) + if err == nil && len(argv) == 1 { + _, found = t.resolve(argv[0]) + } + } + return !found + }) + return found +} diff --git a/pkg/commands/bash_syntax_test.go b/pkg/commands/bash_syntax_test.go new file mode 100644 index 000000000..2caff9065 --- /dev/null +++ b/pkg/commands/bash_syntax_test.go @@ -0,0 +1,203 @@ +package commands + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type routingContainment struct { + commands []string + cleaned chan struct{} +} + +func (c *routingContainment) Prepare(command string, options BashExecOptions) (BashExecOptions, func(), error) { + c.commands = append(c.commands, command) + return options, func() { + if c.cleaned != nil { + close(c.cleaned) + } + }, nil +} + +func TestBashShellSyntaxRouting(t *testing.T) { + tests := []struct { + name, command, want string + }{ + {"semicolon", "memory_echo one;memory_echo two", "one\ntwo"}, + {"and", "memory_echo one&&memory_echo two", "one\ntwo"}, + {"or", "memory_fail ignored||memory_echo recovered", "adapter test exit 7\nrecovered"}, + {"short circuit", "memory_echo one||memory_echo unreachable", "one"}, + {"newline", "memory_echo one\nmemory_echo two", "one\ntwo"}, + {"pipeline", "memory_echo one|memory_upper", "ONE"}, + {"background", "memory_echo one& wait", "one"}, + {"negation", "! memory_fail ignored", "adapter test exit 7"}, + {"environment", `memory_echo "$ROUTING_VALUE"`, "expanded"}, + {"substitution", `memory_echo "$(printf substituted)"`, "substituted"}, + {"braces", "memory_echo {one,two}", "one two"}, + {"glob", "memory_echo *.txt", "one.txt two.txt"}, + {"redirection", "memory_echo one>result;cat result", "one"}, + {"descriptor duplication", "memory_echo one 2>&1", "one"}, + {"literal through shell", "memory_echo ';' '|' '>' ''&&memory_echo done", "; | > \ndone"}, + {"heredoc", "memory_upper <<'EOF'\n# keep this\n\nlast\nEOF\n", "# KEEP THIS\n\nLAST"}, + {"multiline quote", "memory_echo 'first\n# keep this\n\nlast'", "first\n# keep this\n\nlast"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bash, _, _ := newAdapterTestBash(t) + bash.WithEnvironment(map[string]string{"ROUTING_VALUE": "expanded"}) + containment := &routingContainment{} + bash.WithProcessContainment(containment) + dir := t.TempDir() + for _, name := range []string{"one.txt", "two.txt"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0600); err != nil { + t.Fatal(err) + } + } + info, output := runAdapterCommand(t, bash, t.Context(), tt.command, dir) + output = strings.TrimSpace(strings.ReplaceAll(output, "\r\n", "\n")) + if info.ExitCode != 0 || output != tt.want { + t.Fatalf("exit=%d output=%q, want %q", info.ExitCode, output, tt.want) + } + wantShell := tt.name != "multiline quote" + if wantShell && !reflect.DeepEqual(containment.commands, []string{tt.command}) { + t.Fatalf("supervised scripts = %q, want original script %q", containment.commands, tt.command) + } + if !wantShell && (len(containment.commands) != 0 || bash.shellAdapter != nil) { + t.Fatal("literal command unnecessarily started a shell") + } + }) + } +} + +func TestBashLiteralArguments(t *testing.T) { + tests := []struct { + command string + want []string + }{ + {`capture 'file;name.json' ";" '|' '&&' '>' '2>&1'`, []string{"file;name.json", ";", "|", "&&", ">", "2>&1"}}, + {`capture file\;name.json a\ b \* \~`, []string{"file;name.json", "a b", "*", "~"}}, + {`capture '' "" pre'fix'"suffix"`, []string{"", "", "prefixsuffix"}}, + {`capture '$HOME' "\$HOME" '\n' "\n"`, []string{"$HOME", "$HOME", `\n`, `\n`}}, + {"# comment\ncapture value # ignored\n", []string{"value"}}, + {"capture va\\\nlue", []string{"value"}}, + } + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + var got []string + registry, _ := loadTestRegistry(t, commandGroup("capture", "test", Command{ + Name: "capture", Run: func(_ context.Context, e *Execution) (any, error) { + got = append([]string(nil), e.Args...) + return nil, nil + }, + })) + bash := NewBashTool(t.TempDir(), 5, nil) + bash.EnableShellCommands(registry) + t.Cleanup(bash.Close) + containment := &routingContainment{} + bash.WithProcessContainment(containment) + info, _ := runAdapterCommand(t, bash, t.Context(), tt.command, "") + if info.ExitCode != 0 || !reflect.DeepEqual(got, tt.want) { + t.Fatalf("exit=%d args=%q, want %q", info.ExitCode, got, tt.want) + } + if bash.shellAdapter != nil || len(containment.commands) != 0 { + t.Fatal("literal arguments should use the direct command path") + } + }) + } +} + +func TestBashCompoundCommandWithoutAdapter(t *testing.T) { + for _, command := range []string{ + "sample value;echo extra", "sample value&&echo extra", "sample value||echo extra", + "sample value\necho extra", "sample value&", "sample $VALUE", "sample value|&cat", + "sample value|cat;echo extra", "sample value|cat&&echo extra", + "sample one|sample two", "sample one|cat|sample two", "! sample value|cat", + "cat <<'EOF'|sample\nbody\nEOF\n", "sample 'unterminated", + } { + t.Run(command, func(t *testing.T) { + bash := newBashWithPseudo(t, t.TempDir(), &outputCommand{name: "sample", output: "wrong"}) + t.Cleanup(bash.Close) + if _, err := bash.RunForeground(t.Context(), command, BashExecOptions{}); err == nil { + t.Fatal("unsupported composition must not execute as a direct command") + } + }) + } +} + +func TestBashBuiltinDownloadThenCount(t *testing.T) { + const payload = `{"ok":true}` + registry, _ := loadTestRegistry(t, commandGroup("curl", "test", Command{ + Name: "curl", Run: func(_ context.Context, e *Execution) (any, error) { + want := []string{"https://example.invalid/data", "-o", "file.json"} + if !reflect.DeepEqual(e.Args, want) { + return nil, fmt.Errorf("curl args=%q, want %q", e.Args, want) + } + return nil, os.WriteFile(filepath.Join(e.Dir, e.Args[2]), []byte(payload), 0600) + }, + })) + bash := NewBashTool(t.TempDir(), 5, nil) + bash.EnableShellCommands(registry) + t.Cleanup(bash.Close) + dir := t.TempDir() + info, output := runAdapterCommand(t, bash, t.Context(), "curl https://example.invalid/data -o file.json; wc -c < file.json", dir) + if info.ExitCode != 0 || strings.TrimSpace(output) != fmt.Sprint(len(payload)) { + t.Fatalf("exit=%d output=%q, want byte count %d", info.ExitCode, output, len(payload)) + } + data, err := os.ReadFile(filepath.Join(dir, "file.json")) + if err != nil || string(data) != payload { + t.Fatalf("downloaded file=%q err=%v", data, err) + } +} + +func TestBashGluedCommandCancellation(t *testing.T) { + bash, _, state := newAdapterTestBash(t) + containment := &routingContainment{cleaned: make(chan struct{})} + bash.WithProcessContainment(containment) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + execution, err := bash.Start(ctx, "memory_wait ignored;memory_echo unreachable", BashExecOptions{}) + if err != nil { + t.Fatal(err) + } + select { + case <-state.started: + case <-time.After(5 * time.Second): + t.Fatal("registered command did not start") + } + cancel() + for name, done := range map[string]<-chan struct{}{ + "registered command cancellation": state.canceled, + "shell containment cleanup": containment.cleaned, + "session completion": bash.tasks.Done(execution.ID), + } { + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %s", name) + } + } + if len(containment.commands) != 1 { + t.Fatalf("supervised scripts=%q", containment.commands) + } +} + +func TestRegistryRunPreservesArgv(t *testing.T) { + args := []string{";", "|", ">file", "2>&1", ""} + registry, _ := loadTestRegistry(t, commandGroup("capture", "test", Command{ + Name: "capture", Run: func(_ context.Context, e *Execution) (any, error) { + if !reflect.DeepEqual(e.Args, args) { + return nil, fmt.Errorf("args=%q, want %q", e.Args, args) + } + return nil, nil + }, + })) + if _, err := registry.Run(t.Context(), append([]string{"capture"}, args...), &Execution{}); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/commands/command.go b/pkg/commands/command.go index 6c58f0828..6f0da9e6c 100644 --- a/pkg/commands/command.go +++ b/pkg/commands/command.go @@ -3,11 +3,9 @@ package commands import ( "context" "errors" - "fmt" - "strings" + "github.com/chainreactors/aiscan/core/commandline" coreregistry "github.com/chainreactors/aiscan/core/registry" - "github.com/chainreactors/aiscan/core/commandline" ) var ( @@ -26,48 +24,6 @@ type Command struct { Run func(context.Context, *Execution) (any, error) } -func stripShellSyntax(tokens []string) ([]string, error) { - clean := make([]string, 0, len(tokens)) - for _, token := range tokens { - if token == "|" || token == "||" { - return nil, fmt.Errorf("pseudo-commands run in-process and do not support shell pipes (got %q). To limit output, use the scanner's own flags or call a separate filter step", token) - } - if token == "&&" || token == ";" { - return nil, fmt.Errorf("pseudo-commands do not support shell command chaining (got %q). Issue each command separately", token) - } - if isStderrDup(token) { - continue - } - if isFileRedirection(token) { - return nil, fmt.Errorf("pseudo-commands do not support file redirection (got %q); use the returned tool result", token) - } - clean = append(clean, token) - } - return clean, nil -} - -func isStderrDup(token string) bool { - switch token { - case "2>&1", "1>&2", ">&2", ">&1": - return true - default: - return false - } -} - -func isFileRedirection(token string) bool { - switch token { - case ">", ">>", "<", "<<", "2>", "1>", "0<", "&>", "&>>": - return true - } - for _, prefix := range []string{"&>", "2>", "1>", "0<", ">>", ">", "<<", "<"} { - if strings.HasPrefix(token, prefix) { - return true - } - } - return false -} - func normalizeNoColor(name string, args []string) []string { if name != "scan" { return args @@ -81,4 +37,6 @@ func normalizeNoColor(name string, args []string) []string { } func SplitCommandLine(input string) ([]string, error) { return commandline.SplitCommandLine(input) } -func JoinCommandLine(name string, args []string) string { return commandline.JoinCommandLine(name, args) } +func JoinCommandLine(name string, args []string) string { + return commandline.JoinCommandLine(name, args) +} diff --git a/pkg/commands/registry.go b/pkg/commands/registry.go index 9cae850f7..5ed433eef 100644 --- a/pkg/commands/registry.go +++ b/pkg/commands/registry.go @@ -188,16 +188,14 @@ func (r *Registry) Execute(ctx context.Context, name string, execution *Executio return entry.Value.Run(call, execution) } +// Run executes an already parsed argv. Shell syntax belongs to BashTool's +// script boundary; metacharacters here are ordinary argument data. func (r *Registry) Run(ctx context.Context, tokens []string, parent *Execution) (any, error) { if len(tokens) == 0 { return nil, fmt.Errorf("empty command") } - args, err := stripShellSyntax(tokens[1:]) - if err != nil { - return nil, err - } name := tokens[0] - args = normalizeNoColor(name, args) + args := normalizeNoColor(name, append([]string(nil), tokens[1:]...)) if parent == nil { return nil, fmt.Errorf("command %s requires an execution", name) }