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.

Defined Under Namespace

Classes: CappedCapture

Constant Summary collapse

DEFAULT_TIMEOUT =
120
MAX_TIMEOUT =
600
GIT_HARDENED_ENV =

Secondary hardening for #536 (GHSA-9ccr-r5hg-74gf, GitHub Copilot-CLI fix): neutralize the repo-config exec vectors a poisoned ‘.git/config` or a nested bare repo could fire even on a plain `git status`. Injected into the spawn env so the arg-guard (Security::ReadonlyCommands) stays the PRIMARY closer and this is belt-and-suspenders:

GIT_CONFIG_NOSYSTEM   ignore /etc/gitconfig (no attacker system config)
GIT_CONFIG_COUNT/.../safe.bareRepository=explicit
  refuse to operate on a discovered nested BARE repo (whose config
  could carry core.fsmonitor=… and fire on `status`)
GIT_TERMINAL_PROMPT=0 never block on an interactive credential prompt

These only RESTRICT git; they don’t alter any other command.

{
  "GIT_CONFIG_NOSYSTEM" => "1",
  "GIT_TERMINAL_PROMPT" => "0",
  "GIT_CONFIG_COUNT" => "1",
  "GIT_CONFIG_KEY_0" => "safe.bareRepository",
  "GIT_CONFIG_VALUE_0" => "explicit"
}.freeze
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, #display_name, #emit_chunk, #mcp?, #risky?, #to_tool_definition, workspace_root, workspace_roots

Class Method Details

.diff_command?(command) ⇒ Boolean

Returns:

  • (Boolean)


95
96
97
# File 'lib/rubino/tools/shell_tool.rb', line 95

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

.sandbox_refusal_reasonObject

nil when the shell may run, else the one-line refusal (fail-closed tools.sandbox.require with no mechanism). Both shell spawn paths consult this before launching so the refusal is symmetric (slice 2 Part B).



79
80
81
# File 'lib/rubino/tools/shell_tool.rb', line 79

def self.sandbox_refusal_reason
  Security::Sandbox.refusal_reason
end

.sandboxed_bash_argv(script, cwd:) ⇒ Object

SINGLE source of truth for how a shell script is spawned under the OS write-jail (slice 2: foreground here AND background in ShellRegistry share this, so a backgrounded command can’t bypass the jail the foreground enforces — #290/#544). Returns the ‘[env, *argv]` array to splat into Process.spawn: the platform sandbox launcher (sandbox-exec on macOS, rubino-landlock on Linux; [] when off/unavailable) prefixed before `bash -o pipefail -c <script>`, and GIT_HARDENED_ENV merged with the jail’s extra_env (writable roots, never on argv). ‘cwd` derives the writable roots; `script` is the already-wrapped bash source.



70
71
72
73
74
# File 'lib/rubino/tools/shell_tool.rb', line 70

def self.sandboxed_bash_argv(script, cwd:)
  argv = Security::Sandbox.wrap_argv(["bash", "-o", "pipefail", "-c", script], cwd: cwd)
  env  = GIT_HARDENED_ENV.merge(Security::Sandbox.wrap_env(cwd: cwd))
  [env, *argv]
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)


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

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

Instance Method Details

#append_jail_hint(text, cwd) ⇒ Object

Appends the write-jail attribution (#74) to the captured text when the EACCES it carries is an OS-sandbox denial of a write outside the writable roots. Returns the text unchanged when it isn’t (normal perms error, no denial, or the jail isn’t enforcing).



235
236
237
238
# File 'lib/rubino/tools/shell_tool.rb', line 235

def append_jail_hint(text, cwd)
  hint = Security::Sandbox.write_jail_attribution(text, cwd: cwd)
  hint ? "#{text}\n#{hint}" : text
end

#call(arguments) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/rubino/tools/shell_tool.rb', line 162

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

  # Fail-closed (tools.sandbox.require): refuse BOTH foreground and
  # background when the operator requires the OS jail but no mechanism can
  # enforce it (slice 2 Part B). When a mechanism exists this is nil and
  # execution proceeds unchanged.
  if (refusal = self.class.sandbox_refusal_reason)
    return { output: "Error: #{refusal}", error_code: :denied_command }
  end

  if background
    # Background shells are detached and outlive the turn; the persistent
    # session cwd (a per-call carry-over) deliberately does NOT apply to
    # them — they run in the explicitly resolved cwd, like before (#544/#545).
    spawn_background(command, working_dir)
  else
    run = execute_foreground(command, working_dir, timeout)
    # Attribute an OS write-jail denial (#74): an EACCES against a path
    # outside the writable roots reads like a plain perms error, so the
    # model retries with chmod/sudo instead of writing in the workspace.
    # Append a one-line hint when the jail is the real cause. No-op text
    # (nil) when it isn't a jailed-write denial.
    run[:text] = append_jail_hint(run[:text], working_dir)
    # 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),
      # Routing context for the compression seam: the stream_kind lets the
      # router send a diff (`git diff`) through UNTOUCHED — its own +/-
      # channel — while a test/build/lint dump routes to LogCompressor. The
      # human `body` preview above is the REAL scrollback and is never
      # compressed.
      compress_hint: { stream_kind: @stream_kind } }
  end
end

#compression_enabled?Boolean

Returns:

  • (Boolean)


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

def compression_enabled?
  Rubino.configuration.tool_output_compression_enabled?
rescue StandardError
  false
end

#compression_noteObject

Advertised only when the feature is on: explains command-output compression, the opt-out, and that the original is retrievable.



116
117
118
119
120
121
122
# File 'lib/rubino/tools/shell_tool.rb', line 116

def compression_note
  return "" unless compression_enabled?

  " Long command output (test/build/lint dumps) may be COMPRESSED — every failure + " \
    "the summary kept, passing noise dropped — to save tokens; the full output is always " \
    "retrievable via the appended pointer. Pass compress:false to force verbatim output."
end

#descriptionObject



103
104
105
106
107
108
109
110
111
112
# File 'lib/rubino/tools/shell_tool.rb', line 103

def description
  base = "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."
  base + compression_note
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.



251
252
253
254
255
256
257
258
259
260
# File 'lib/rubino/tools/shell_tool.rb', line 251

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



262
263
264
265
266
267
268
269
# File 'lib/rubino/tools/shell_tool.rb', line 262

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



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/rubino/tools/shell_tool.rb', line 130

def input_schema
  props = {
    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."
    }
  }
  if compression_enabled?
    props[:compress] = {
      type: "boolean",
      description: "Set false to skip output compression and return verbatim output (default true)."
    }
  end
  { type: "object", properties: props, required: %w[command] }
end

#nameObject



99
100
101
# File 'lib/rubino/tools/shell_tool.rb', line 99

def name
  "shell"
end

#risk_levelObject



158
159
160
# File 'lib/rubino/tools/shell_tool.rb', line 158

def risk_level
  :high
end

#shell_error_code(run) ⇒ Object



240
241
242
243
244
245
246
247
# File 'lib/rubino/tools/shell_tool.rb', line 240

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