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
Instance Attribute Summary
Attributes inherited from Base
#cancel_token, #read_tracker, #stream_chunk
Class Method Summary collapse
-
.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
.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
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 |
#description ⇒ Object
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_schema ⇒ Object
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 |
#name ⇒ Object
42 43 44 |
# File 'lib/rubino/tools/shell_tool.rb', line 42 def name "shell" end |
#risk_level ⇒ Object
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 |