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
DIFF_COMMAND =

True when the command’s primary output is a unified diff the dev is asking to SEE — ‘git diff`, `git show`, `git log -p`, or plain `diff`. Matched on the FIRST stage of the command only (anything piped into a pager/`head`/grep is the user already reshaping it, so don’t force diff-render on that). Word-boundary anchored so ‘gitdiff`/`diffstat` don’t false-positive, and ‘git difftool` (opens an editor) is excluded.

/\A\s*
  (?:git\s+(?:diff|show|whatchanged)(?!\w)(?!\S*tool)
    |git\s+log\b[^|&;]*\s-p\b
    |diff\s)
/x

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk, #stream_kind

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

.diff_command?(command) ⇒ Boolean

Returns:

  • (Boolean)


54
55
56
# File 'lib/rubino/tools/shell_tool.rb', line 54

def self.diff_command?(command)
  DIFF_COMMAND.match?(command.to_s)
end

.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



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/rubino/tools/shell_tool.rb', line 101

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?

  # "show me the diff" DX: when the command's job is to PRODUCE a diff
  # (`git diff`, `git show`, `diff …`), render its output as a real diff —
  # +/- coloring AND full hunks (no 3-line collapse) — instead of dimming
  # and truncating it like any other shell dump (G3). The streaming lambda
  # and the end-of-call body both read this hint.
  @stream_kind = self.class.diff_command?(command) ? :diff : :plain

  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: @stream_kind || :plain,
      exit_code: run[:exit_code],
      timed_out: run[:timed_out],
      cancelled: run[:cancelled],
      error_code: shell_error_code(run) }
  end
end

#descriptionObject



62
63
64
65
66
67
68
69
70
# File 'lib/rubino/tools/shell_tool.rb', line 62

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.



158
159
160
161
162
163
164
165
166
167
# File 'lib/rubino/tools/shell_tool.rb', line 158

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



169
170
171
172
173
174
175
176
# File 'lib/rubino/tools/shell_tool.rb', line 169

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



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/rubino/tools/shell_tool.rb', line 72

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



58
59
60
# File 'lib/rubino/tools/shell_tool.rb', line 58

def name
  "shell"
end

#risk_levelObject



97
98
99
# File 'lib/rubino/tools/shell_tool.rb', line 97

def risk_level
  :high
end

#shell_error_code(run) ⇒ Object



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

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