Class: Aidp::ShellExecutor

Inherits:
Object
  • Object
show all
Defined in:
lib/aidp/shell_executor.rb

Overview

Shell command executor wrapper for testability

Commands are always executed in argument form so shell metacharacters in any value are treated as literal data:

  1. run_argv(*command) - Captures output without shell interpretation
  2. system(*args) - Wraps Kernel.system() with optional output suppression

In tests, set ShellExecutor.suppress_output = true to suppress all system() output without changing any production code behavior.

Examples:

Production usage

executor = Aidp::ShellExecutor.new
executor.system("git", "fetch", "origin")  # Output shown normally

Test setup (in spec_helper.rb)

Aidp::ShellExecutor.suppress_output = true

Defined Under Namespace

Classes: Result

Class Attribute Summary collapse

Instance Method Summary collapse

Class Attribute Details

.suppress_output ⇒ Object

When true, system() calls will have output redirected to /dev/null Default: false (output shown normally)



25
26
27
# File 'lib/aidp/shell_executor.rb', line 25

def suppress_output
  @suppress_output
end

Instance Method Details

#run_argv(*command, **opts) ⇒ Result

Run a command without shell interpretation and capture its output

The command and its arguments are passed directly to the operating system, so shell metacharacters in any argument are treated as literal data. Prefer this over #run whenever an argument may contain externally supplied values.

Parameters:

  • command (Array<String>) —

    the command and its arguments

Returns:

  • (Result) —

    captured stdout/stderr with the exit status

Raises:

  • (ArgumentError)


64
65
66
67
68
69
70
71
72
73
74
# File 'lib/aidp/shell_executor.rb', line 64

def run_argv(*command, **opts)
  require "open3"
  program, *args = command.map(&:to_s)
  raise ArgumentError, "command must not be blank" if program.nil? || program.empty?

  # The [program, argv0] form forces direct execution even when a command
  # has no arguments. Passing one string to Open3 can otherwise invoke a
  # shell when that string contains shell metacharacters.
  stdout, stderr, status = Open3.capture3([program, program], *args, **opts)
  Result.new(stdout: stdout, stderr: stderr, exit_status: status.exitstatus)
end

#run_line(command, **opts) ⇒ Result

Run a command line without shell interpretation and capture its output

The line is tokenized with Shellwords, so quoting in the command string is honored as argument boundaries, but the resulting argv is passed directly to the operating system. Shell metacharacters (operators, redirections, substitutions) are never interpreted, which makes this safe for command lines built from untrusted input.

Parameters:

  • command (String) —

    the command line to run

  • opts (Hash) —

    options forwarded to Open3.capture3 (e.g. chdir:)

Returns:

  • (Result) —

    captured stdout/stderr with the exit status

Raises:

  • (ArgumentError) —

    when the command line is blank, has unbalanced quotes, or uses shell operators/redirections



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/aidp/shell_executor.rb', line 89

def run_line(command, **opts)
  require "shellwords"
  argv = Shellwords.split(command.to_s)
  raise ArgumentError, "command must not be blank" if argv.empty?

  # A leading NAME=value token is a shell environment assignment; without
  # a shell it would be treated as the program name and fail to run.
  if argv.first.match?(/\A[A-Za-z_][A-Za-z0-9_]*=/)
    raise ArgumentError,
      "environment variable assignments are not supported; " \
      "prefix the command with env (e.g. env VAR=value command)"
  end

  # Operator and redirection tokens signal an intent to use shell
  # features, which are intentionally unsupported here; reject them
  # loudly rather than running them as literal arguments. A leading file
  # descriptor digit (e.g. `2>`) still introduces a shell redirect.
  if argv.any? { |token| %w[&& || ; |].include?(token) || token.match?(/\A\d*[<>&]/) }
    raise ArgumentError,
      "shell operators are not supported in configured commands; " \
      "split into separate commands instead: #{command.inspect}"
  end

  # A single token that still contains shell metacharacters would be
  # handed to Process.spawn as a lone string, which Ruby routes through
  # sh -c; reject it rather than let the shell interpret it.
  if argv.length == 1 && argv.first.match?(/[*?\[\]{}()<>|;&$\\"'`\r\n~#=]/)
    raise ArgumentError,
      "shell metacharacters in a single-token command are not supported; " \
      "split into program and arguments"
  end

  run_argv(*argv, **opts)
end

#system(*args, **opts) ⇒ Boolean?

Run a command via system(), optionally suppressing output

When suppress_output is true, output is redirected to /dev/null unless explicit out:/err: options are provided.

Commands are always executed in argument form so a shell never interprets any value. A leading environment hash is forwarded unchanged; otherwise the [program, argv0] form forces direct execution even when a single command string is supplied.

Parameters:

  • args (Array) —

    Arguments passed to Kernel.system

  • opts (Hash) —

    Options passed to Kernel.system

Returns:

  • (Boolean, nil) —

    Same as Kernel.system



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/aidp/shell_executor.rb', line 137

def system(*args, **opts)
  if self.class.suppress_output && !opts.key?(:out) && !opts.key?(:err)
    opts = opts.merge(out: File::NULL, err: File::NULL)
  end

  if args.first.is_a?(Hash)
    environment, *command = args
    program, *rest = command.map(&:to_s)
    raise ArgumentError, "command must not be blank" if program.nil? || program.empty?

    Kernel.system(environment, [program, program], *rest, **opts)
  else
    program, *rest = args.map(&:to_s)
    raise ArgumentError, "command must not be blank" if program.nil? || program.empty?

    Kernel.system([program, program], *rest, **opts)
  end
end