Class: Rubino::Tools::ShellTool

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/tools/shell_tool.rb

Overview

Executes shell commands.

Modes:

- foreground (default): blocks until exit or `timeout` seconds, then
  SIGTERMs the process group and returns whatever was captured.
- background (`run_in_background: true`): registers the process with
  ShellRegistry, returns a run_id immediately. Read its output later
  with `shell_output`, terminate it with `shell_kill`.

Gatekeeping (allowlist, deny rules, approval prompts) lives in Security::ApprovalPolicy and is enforced by the ToolExecutor before we get here — this class only runs the command and resolves cwd.

As defense-in-depth, #call re-checks the command against the hardline blocklist (Security::HardlineGuard — the single source of truth, also used by ApprovalPolicy). yolo skips approvals by design, but the point of yolo is “trust the model to move fast”, not “let it wipe the root filesystem if it confuses paths” — so catastrophic, unrecoverable commands are refused here even if the policy was somehow bypassed.

Constant Summary collapse

DEFAULT_TIMEOUT =
120
MAX_TIMEOUT =
600
SIGPIPE_EXIT =

128 + SIGPIPE(13): under ‘pipefail`, a benign early-exit consumer (`cmd | head -1`) makes an upstream stage report SIGPIPE and the pipeline returns 141 even though nothing actually went wrong.

141

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#cancellation_requested?, #config_key, #emit_chunk, #risky?, #to_tool_definition, workspace_root, workspace_roots

Class Method Details

.success_exit?(code) ⇒ Boolean

Single decision point for “does this exit code count as success?”. Used by both the [Exit code: …] suffix and the ✓/✗ presentation (via shell_error_code → Result#errorish?) so the two can’t drift.

Returns:

  • (Boolean)


38
39
40
# File 'lib/rubino/tools/shell_tool.rb', line 38

def self.success_exit?(code)
  code.zero? || code == SIGPIPE_EXIT
end

Instance Method Details

#call(arguments) ⇒ Object



85
86
87
88
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/rubino/tools/shell_tool.rb', line 85

def call(arguments)
  command    = arguments["command"]           || arguments[:command]
  cwd        = arguments["cwd"]               || arguments[:cwd]
  background = arguments["run_in_background"] || arguments[:run_in_background] || false
  timeout    = arguments["timeout"]           || arguments[:timeout] || DEFAULT_TIMEOUT
  timeout    = [[timeout.to_i, 1].max, MAX_TIMEOUT].min

  return "Error: command is required" if command.nil? || command.to_s.empty?

  if (denied = destructive_pattern_match(command))
    return { output: "Error: refusing to run #{denied} — this is hardcoded as " \
                     "destructive and not overridable by --yolo. " \
                     "If you genuinely need this, run it manually outside the agent.",
             error_code: :denied_command }
  end

  working_dir = resolve_cwd(cwd)
  return "Error: cannot access working directory: #{cwd.inspect}" unless working_dir

  if background
    spawn_background(command, working_dir)
  else
    run = execute_foreground(command, working_dir, timeout)
    # exit_code / timed_out / cancelled are surfaced as structured
    # keys so downstream code (and the model) doesn't have to parse
    # `[Exit code: N]` out of free-form text to know whether the
    # command succeeded. The text suffix stays for visual continuity
    # in the scrollback and for tests that grep for it.
    { output: run[:text],
      metrics: foreground_metric(run),
      body: Util::Output.preview(run[:text]),
      body_kind: :plain,
      exit_code: run[:exit_code],
      timed_out: run[:timed_out],
      cancelled: run[:cancelled],
      error_code: shell_error_code(run) }
  end
end

#descriptionObject



46
47
48
49
50
51
52
53
54
# File 'lib/rubino/tools/shell_tool.rb', line 46

def description
  "Execute a shell command. " \
    "Foreground: blocks until the command exits or `timeout` seconds elapse " \
    "(default #{DEFAULT_TIMEOUT}s, max #{MAX_TIMEOUT}s). " \
    "Background: pass `run_in_background: true` to fire-and-forget; the tool " \
    "returns a run_id. Use the `shell_output` tool to read its stdout/stderr, " \
    "`shell_input` to answer an interactive prompt it emits (Y/N, menu), " \
    "and `shell_kill` to terminate it."
end

#foreground_metric(run) ⇒ Object

One-liner for the ‘done · shell` header. Reads the structured run fields directly — no regex archaeology on the text suffix.



135
136
137
138
139
140
141
142
143
144
# File 'lib/rubino/tools/shell_tool.rb', line 135

def foreground_metric(run)
  status = if run[:timed_out]            then "timeout"
           elsif run[:cancelled]         then "cancelled"
           elsif run[:shell_error]       then "shell error"
           elsif run[:exit_code].nil?    then "no exit"
           elsif run[:exit_code].zero?   then "exit 0"
           else                               "exit #{run[:exit_code]}"
           end
  "#{status} · #{format_ms(run[:duration_ms])}"
end

#format_ms(ms) ⇒ Object



146
147
148
149
150
151
152
153
# File 'lib/rubino/tools/shell_tool.rb', line 146

def format_ms(ms)
  if ms < 1000      then "#{ms}ms"
  elsif ms < 60_000 then "#{(ms / 1000.0).round(1)}s"
  else
    mins, rem = ms.divmod(60_000)
    "#{mins}m#{(rem / 1000.0).round}s"
  end
end

#input_schemaObject



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/rubino/tools/shell_tool.rb', line 56

def input_schema
  {
    type: "object",
    properties: {
      command: {
        type: "string",
        description: "The shell command to execute"
      },
      cwd: {
        type: "string",
        description: "Working directory (defaults to current)"
      },
      timeout: {
        type: "integer",
        description: "Foreground timeout in seconds (default #{DEFAULT_TIMEOUT}, max #{MAX_TIMEOUT}). Ignored when run_in_background is true."
      },
      run_in_background: {
        type: "boolean",
        description: "If true, start the command detached and return a run_id immediately."
      }
    },
    required: %w[command]
  }
end

#nameObject



42
43
44
# File 'lib/rubino/tools/shell_tool.rb', line 42

def name
  "shell"
end

#risk_levelObject



81
82
83
# File 'lib/rubino/tools/shell_tool.rb', line 81

def risk_level
  :high
end

#shell_error_code(run) ⇒ Object



124
125
126
127
128
129
130
131
# File 'lib/rubino/tools/shell_tool.rb', line 124

def shell_error_code(run)
  return :timeout       if run[:timed_out]
  return :cancelled     if run[:cancelled]
  return :shell_error   if run[:shell_error]
  return :exit_nonzero  if run[:exit_code] && !self.class.success_exit?(run[:exit_code])

  nil
end