Class: Pikuri::Code::Bash

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

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

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

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

Defined Under Namespace

Modules: Sandbox

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.
  - 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

Instance Method Summary collapse

Constructor Details

#initialize(workspace:, confirmer:, sandbox: Sandbox::NONE) ⇒ Bash

Parameters:

  • workspace (Pikuri::Workspace::Filesystem)

    captured for chdir; commands run in workspace.project_root, always. Even when the surrounding Ruby process has already chdir’d to the project root (e.g. bin/pikuri-code does this at startup), Bash still passes the explicit chdir: — so a sub-agent, future host, or anyone embedding Bash directly gets the same predictable cwd. Bash does NOT path-resolve individual arguments — the command string is opaque shell syntax.

  • confirmer (Pikuri::Workspace::Confirmer)

    consulted before every command.

  • sandbox (Code::Bash::Sandbox) (defaults to: Sandbox::NONE)

    filesystem-sandbox seam (default Pikuri::Code::Bash::Sandbox::NONE — identity passthrough). Pass workspace)+ for an isolated subprocess whose filesystem view is the workspace’s readable/writable roots plus the OS-runtime baseline. See Sandbox for the rationale.

Raises:

  • (RuntimeError)

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



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.

Parameters:

  • workspace (Pikuri::Workspace::Filesystem)
  • confirmer (Pikuri::Workspace::Confirmer)
  • sandbox (Code::Bash::Sandbox) (defaults to: Sandbox::NONE)

    wraps the spawned argv; Pikuri::Code::Bash::Sandbox::NONE for identity passthrough, Pikuri::Code::Bash::Sandbox::Bubblewrap for filesystem isolation.

  • 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)


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