Class: Pikuri::Tool::Bash
- Inherits:
-
Pikuri::Tool
- Object
- Pikuri::Tool
- Pikuri::Tool::Bash
- Defined in:
- lib/pikuri/tool/bash.rb
Overview
The bash tool — run an arbitrary shell command in the workspace. Instantiating Tool::Bash.new(workspace: ws, confirmer: c) produces a tool whose #to_ruby_llm_tool wiring is identical to any bundled tool’s. Same shape as Write (workspace + confirmer captured by the execute closure at construction).
Confirmation
Every command requires confirmation. Bash composes the full prompt (header line, $ <command> echo, (y/n)? cue) and hands it to the confirmer; the confirmer renders + parses the answer. The command echo passes through Bash.visible which escapes control bytes so the model can’t smuggle a r033[2K rm -rf ~/ behind the displayed text. Execution uses the raw command; only the display is sanitized.
Subprocess wiring
The command runs through Subprocess.spawn with argv:
timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
bash -c (no -l) — no profile/rc sourcing, no inherited login environment beyond what pikuri itself runs in. timeout(1) from GNU coreutils handles the SIGTERM-then-SIGKILL race; we never use Ruby’s Timeout.timeout (can’t reliably kill subprocesses). The –kill-after=5s gives the command 5 seconds to handle SIGTERM cleanly before SIGKILL is sent.
Timeout detection
GNU coreutils’ timeout exits 124 after a successful SIGTERM, 137 after escalating to SIGKILL. We treat both as “command timed out”. A third code, 125, is also accepted: uutils-coreutils 0.2.2 (the Rust reimplementation shipped on some distros) sends the SIGTERM correctly but then mis-reports 125 (“timeout itself failed”) instead of 124 whenever --kill-after is in play. False- positive risk on real GNU coreutils is low — our argv is fixed and well-formed, so a 125 there would indicate a misconfigured PATH rather than a routine timeout.
Caveat: 137 is ambiguous — a command killed by the OOM-killer also exits 137. v1 accepts the mis-classification; the observation tells the user “sent SIGTERM, then SIGKILL” regardless.
Output handling
Combined stdout+stderr (popen2e). Head+tail truncation at OUTPUT_HEAD + OUTPUT_TAIL bytes with a marker reporting both bytes-omitted and the original total — the model needs the scale to decide whether to re-run with head/tail/grep.
Backgrounded subprocesses
Plain cmd & does NOT detach — the backgrounded child inherits our combined-output pipe by default, so Subprocess#wait blocks on io.read until the backgrounded process exits. The model must redirect fds away from our pipe to genuinely background: cmd >/dev/null 2>&1 &. Such commands stay in our pgroup and get SIGTERM on pikuri exit via Subprocess.cleanup!; nohup / setsid plus redirection opt out of cleanup.
Refusals
All returned as “Error: …” observations:
-
Empty / whitespace-only
command→ fast reject before confirming. -
timeoutoutside [1, MAX_TIMEOUT] → bounds error. -
User declined the confirmation → “Error: user declined …”.
-
timeoutexit (124/137) → timeout error with partial output.
Constant Summary collapse
- LOGGER =
Pikuri-convention per-module logger; the
Bashprogname tags the construction-time warning below so it’s clear in the sharedPikuri.log_iostream which tool issued it. Pikuri.logger_for('Bash')
- DEFAULT_TIMEOUT =
Returns default value of the
timeoutparameter (seconds). 120- MAX_TIMEOUT =
Returns hard upper bound on the
timeoutparameter. 600- KILL_AFTER =
Returns grace period between SIGTERM and SIGKILL, passed to timeout –kill-after=….
'5s'- OUTPUT_HEAD =
Returns bytes preserved from the start of the output when the combined-output stream exceeds OUTPUT_HEAD + OUTPUT_TAIL.
15 * 1024
- OUTPUT_TAIL =
Returns bytes preserved from the end of the output.
15 * 1024
- DESCRIPTION =
Description shown to the LLM. opencode-shape: summary +
Usage:bullets. Per-parameter constraints (default, max) live in the parameter descriptions. <<~DESC Run a bash command in the workspace. Usage: - Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines. - Prefer `read` / `write` / `edit` / `grep` / `glob` over `cat` / `sed` / `rg` / `find` — they're workspace-resolved. - Each call starts in the workspace root; there is no pwd persistence between calls. Use `cd /path && cmd` in one command. - stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`). - Plain `cmd &` does NOT detach — the backgrounded process inherits our output pipe and blocks. To genuinely background, redirect fds: `cmd >/dev/null 2>&1 &`. Add `nohup` or `setsid` to survive pikuri exit. - Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc. - Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume. - Non-zero exit status is a normal observation, not an error. - The user must confirm every command before it runs; on rejection an Error is returned. DESC
Constants inherited from Pikuri::Tool
CALCULATOR, FETCH, WEB_SCRAPE, WEB_SEARCH
Instance Attribute Summary
Attributes inherited from Pikuri::Tool
#description, #execute, #name, #parameters
Class Method Summary collapse
-
.run(workspace:, confirmer:, command:, description:, timeout:) ⇒ String
Bounds-check, confirm, spawn, and render the observation.
Instance Method Summary collapse
- #initialize(workspace:, confirmer:) ⇒ Bash constructor
Methods inherited from Pikuri::Tool
Constructor Details
#initialize(workspace:, confirmer:) ⇒ Bash
128 129 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 157 158 159 160 161 |
# File 'lib/pikuri/tool/bash.rb', line 128 def initialize(workspace:, confirmer:) Bash.send(:check_binaries!) # Prototype-stage capability advisory. The Bash tool runs commands # with pikuri's own UID and inherits its filesystem view — there is # no chroot, container, seccomp, or syscall filter today. Anything # readable to the user is readable to the LLM via +cat ~/.ssh/id_*+ # / +aws configure list+ / etc. The per-command +Confirmer+ is the # only line of defense; logged loud so anyone wiring this tool up # sees the warning at startup, not on the day of an incident. LOGGER.warn( 'Tool::Bash is a prototype: commands run unsandboxed under your UID and can read ' \ 'sensitive files (~/.ssh, AWS credentials, browser sessions, ...). Use with care; ' \ 'a future release will gate this behind a sandbox.' ) super( name: 'bash', description: DESCRIPTION, parameters: Parameters.build { |p| p.required_string :command, 'Bash command to execute. Multi-line is fine. ' \ 'Example: "ls -la lib/".' p.optional_string :description, 'Short 3-7 word label shown to the user alongside ' \ 'the command, e.g. "Run unit tests".' p.optional_integer :timeout, "Timeout in seconds. Defaults to #{DEFAULT_TIMEOUT}, " \ "max #{MAX_TIMEOUT}, e.g. 300." }, execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) { Bash.run(workspace: workspace, confirmer: confirmer, command: command, description: description, timeout: timeout) } ) end |
Class Method Details
.run(workspace:, confirmer:, command:, description:, timeout:) ⇒ String
Bounds-check, confirm, spawn, and render the observation. Returns either “$ …n<out>nnexit status: N” on a normal exit, or “Error: …” on rejection / timeout / bad inputs.
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 |
# File 'lib/pikuri/tool/bash.rb', line 173 def self.run(workspace:, confirmer:, command:, description:, timeout:) return 'Error: empty bash command.' if command.strip.empty? return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1 return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT prompt = compose_prompt(command: command, description: description, timeout: timeout) return 'Error: user declined the bash command.' unless confirmer.confirm?(prompt: prompt) result = Pikuri::Subprocess.spawn( 'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s", 'bash', '-c', command, chdir: workspace.cwd.to_s ).wait output = truncate(result.output) exit_code = result.status.exitstatus # 124 (GNU SIGTERM), 137 (GNU SIGKILL), 125 (uutils-coreutils # 0.2.2 bug when --kill-after is set). See class header. if exit_code == 124 || exit_code == 137 || exit_code == 125 "Error: command timed out after #{timeout}s (sent SIGTERM, then SIGKILL).\n\n" \ "$ #{visible(command)}\n#{output}" else "$ #{visible(command)}\n#{output}\n\nexit status: #{exit_code}" end end |