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.
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 promptThese 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
- .diff_command?(command) ⇒ Boolean
-
.sandbox_refusal_reason ⇒ Object
nil when the shell may run, else the one-line refusal (fail-closed tools.sandbox.require with no mechanism).
-
.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).
-
.success_exit?(code) ⇒ Boolean
Single decision point for “does this exit code count as success?”.
Instance Method Summary collapse
-
#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.
- #call(arguments) ⇒ Object
- #compression_enabled? ⇒ Boolean
-
#compression_note ⇒ Object
Advertised only when the feature is on: explains command-output compression, the opt-out, and that the original is retrievable.
- #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, #display_name, #emit_chunk, #mcp?, #risky?, #to_tool_definition, workspace_root, workspace_roots
Class Method Details
.diff_command?(command) ⇒ 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_reason ⇒ Object
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.
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
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_note ⇒ Object
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 |
#description ⇒ Object
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_schema ⇒ Object
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 |
#name ⇒ Object
99 100 101 |
# File 'lib/rubino/tools/shell_tool.rb', line 99 def name "shell" end |
#risk_level ⇒ Object
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 |