Class: Pikuri::Tool::Bash

Inherits:
Pikuri::Tool show all
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.

  • timeout outside [1, MAX_TIMEOUT] → bounds error.

  • User declined the confirmation → “Error: user declined …”.

  • timeout exit (124 / 137) → timeout error with partial output.

Constant Summary collapse

LOGGER =

Pikuri-convention per-module logger; the Bash progname tags the construction-time warning below so it’s clear in the shared Pikuri.log_io stream which tool issued it.

Returns:

  • (Logger)
Pikuri.logger_for('Bash')
DEFAULT_TIMEOUT =

Returns default value of the timeout parameter (seconds).

Returns:

  • (Integer)

    default value of the timeout parameter (seconds).

120
MAX_TIMEOUT =

Returns hard upper bound on the timeout parameter.

Returns:

  • (Integer)

    hard upper bound on the timeout parameter.

600
KILL_AFTER =

Returns grace period between SIGTERM and SIGKILL, passed to timeout –kill-after=….

Returns:

  • (String)

    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.

Returns:

  • (Integer)

    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.

Returns:

  • (Integer)

    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.

Returns:

  • (String)
<<~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

Instance Method Summary collapse

Methods inherited from Pikuri::Tool

#run, #to_ruby_llm_tool

Constructor Details

#initialize(workspace:, confirmer:) ⇒ Bash

Parameters:

  • workspace (Tool::Workspace)

    captured for chdir; commands run in workspace.cwd. Bash does NOT path-resolve individual arguments — the command string is opaque shell syntax.

  • confirmer (Tool::Confirmer)

    consulted before every command.

Raises:

  • (RuntimeError)

    if bash or timeout aren’t on PATH; fail-loud at construction rather than the first tool call.



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.

Parameters:

  • workspace (Tool::Workspace)
  • confirmer (Tool::Confirmer)
  • command (String)

    raw command as supplied by the LLM

  • description (String, nil)

    optional short label for the user

  • timeout (Integer)

    seconds before SIGTERM is sent

Returns:

  • (String)


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