Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 44 additions & 122 deletions pkg/commands/bash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
126 changes: 126 additions & 0 deletions pkg/commands/bash_syntax.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading