Class: Rubino::Tools::ShellTool
- 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
- .diff_command?(command) ⇒ Boolean
-
.success_exit?(code) ⇒ Boolean
Single decision point for “does this exit code count as success?”.
Instance Method Summary collapse
- #call(arguments) ⇒ Object
- #description ⇒ Object
-
#foreground_metric(run) ⇒ Object
One-liner for the ‘done · shell` header.
- #format_ms(ms) ⇒ Object
- #input_schema ⇒ Object
- #name ⇒ Object
- #risk_level ⇒ Object
- #shell_error_code(run) ⇒ Object
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
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.
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 |
#description ⇒ Object
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_schema ⇒ Object
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 |
#name ⇒ Object
58 59 60 |
# File 'lib/rubino/tools/shell_tool.rb', line 58 def name "shell" end |
#risk_level ⇒ Object
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 |