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

After the direct child exits, how long to wait for the merged output pipe to reach EOF before concluding a DETACHED background child (server &) inherited it and is holding it open. Matches Codex's IO_DRAIN_TIMEOUT (2s); a normal command EOFs the instant its child exits, so this adds no latency to the common path — it only bounds the foreground-& hang.

2
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)


101
102
103
# File 'lib/rubino/tools/shell_tool.rb', line 101

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).



85
86
87
# File 'lib/rubino/tools/shell_tool.rb', line 85

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.



76
77
78
79
80
# File 'lib/rubino/tools/shell_tool.rb', line 76

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)


63
64
65
# File 'lib/rubino/tools/shell_tool.rb', line 63

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).



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

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

#call(arguments) ⇒ Object



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
230
231
232
233
234
235
236
237
238
# File 'lib/rubino/tools/shell_tool.rb', line 171

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)


133
134
135
136
137
# File 'lib/rubino/tools/shell_tool.rb', line 133

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.



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

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



109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/rubino/tools/shell_tool.rb', line 109

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. " \
         "For a LONG-LIVED process (a dev/web server, a watcher) ALWAYS use " \
         "`run_in_background: true` — do NOT start it in the foreground with a " \
         "trailing `&`: the foreground call would block until the timeout."
  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.



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

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



271
272
273
274
275
276
277
278
# File 'lib/rubino/tools/shell_tool.rb', line 271

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



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/rubino/tools/shell_tool.rb', line 139

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



105
106
107
# File 'lib/rubino/tools/shell_tool.rb', line 105

def name
  "shell"
end

#risk_levelObject



167
168
169
# File 'lib/rubino/tools/shell_tool.rb', line 167

def risk_level
  :high
end

#shell_error_code(run) ⇒ Object



249
250
251
252
253
254
255
256
# File 'lib/rubino/tools/shell_tool.rb', line 249

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