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- 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/configor a nested bare repo could fire even on a plaingit 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 plaindiff. 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 sogitdiff/diffstatdon't false-positive, andgit 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 · shellheader. - #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
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_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).
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.
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
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_note ⇒ Object
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 |
#description ⇒ Object
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_schema ⇒ Object
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 |
#name ⇒ Object
105 106 107 |
# File 'lib/rubino/tools/shell_tool.rb', line 105 def name "shell" end |
#risk_level ⇒ Object
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 |