Class: Pikuri::Code::Bash
- Inherits:
-
Tool
- Object
- Tool
- Pikuri::Code::Bash
- Defined in:
- lib/pikuri/code/bash.rb,
lib/pikuri/code/bash/sandbox.rb
Overview
The bash tool — run an arbitrary shell command in the workspace. Instantiating Code::Bash.new(workspace: ws, confirmer: c) produces a tool whose Tool#to_ruby_llm_tool wiring is identical to any bundled tool’s. Same shape as Workspace::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.
Defined Under Namespace
Modules: Sandbox
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. - Working directory is ALWAYS the project root: `pwd` returns it, and relative paths in commands resolve from there. To operate in a subfolder, chain `cd` in the same command (`cd src/foo && make test`) — `cd` does NOT persist across calls; each call starts fresh at the project root. - 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
Class Method Summary collapse
-
.run(workspace:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE) ⇒ String
Bounds-check, confirm, spawn, and render the observation.
Instance Method Summary collapse
Constructor Details
#initialize(workspace:, confirmer:, sandbox: Sandbox::NONE) ⇒ Bash
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/pikuri/code/bash.rb', line 142 def initialize(workspace:, confirmer:, sandbox: Sandbox::NONE) Bash.send(:check_binaries!) # Capability advisory when no sandbox is in play. Without a # sandbox, +bash+ runs with pikuri's own UID + filesystem view — # 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 in that mode. The # bundled {Sandbox::Bubblewrap} addresses this concern with a # filesystem-restricted subprocess; warn only when the host has # opted out (or never opted in). if sandbox.equal?(Sandbox::NONE) LOGGER.warn( 'Code::Bash is unsandboxed: commands run under your UID and can read ' \ 'sensitive files (~/.ssh, AWS credentials, browser sessions, ...). ' \ 'Use Sandbox::Bubblewrap or an outer container for isolation.' ) end 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, sandbox: sandbox, command: command, description: description, timeout: timeout) } ) end |
Class Method Details
.run(workspace:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE) ⇒ 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.
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 |
# File 'lib/pikuri/code/bash.rb', line 193 def self.run(workspace:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE) 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) argv = sandbox.wrap([ 'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s", 'bash', '-c', command ]) result = Pikuri::Subprocess.spawn(*argv, chdir: workspace.project_root.to_s, env: workspace.env).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 |