Class: Pikuri::Code::Bash
- Inherits:
-
Tool
- Object
- Tool
- Pikuri::Code::Bash
- Defined in:
- lib/pikuri/code/bash.rb,
lib/pikuri/code/bash/sandbox.rb,
lib/pikuri/code/bash/tokenizer.rb,
lib/pikuri/code/bash/passive_command_detector.rb
Overview
The bash tool — run an arbitrary shell command in the workspace.
Code::Bash.new(filesystem: fs, confirmer: c) produces a tool whose
Tool#to_ruby_llm_tool wiring is identical to any bundled tool's
(filesystem + confirmer captured by the execute closure).
Confirmation
Every command is confirmed (unless a passive_detector pre-approves it).
Bash composes a semantic Workspace::Confirmer::Request — a
question plus $ <command> detail — and hands it to the confirmer, which
owns ALL presentation and medium-appropriate escaping of the raw bytes
(terminal neutralizes control bytes, web client HTML-escapes). The request
carries the command verbatim so each renderer escapes for its own medium.
The observation echo (+$ ...+ in the tool result) passes through
Bash.visible, so the model can't smuggle a \r\033[2K rm -rf ~/ behind it
either. Execution uses the raw command; only displays are sanitized.
Subprocess wiring
timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
bash -c (no -l) — no profile/rc sourcing. timeout(1) from GNU
coreutils handles the SIGTERM-then-SIGKILL race (Ruby's Timeout.timeout
can't reliably kill subprocesses); --kill-after=5s gives 5s to handle
SIGTERM before SIGKILL. The environment is de-bundlerized first, or a
bundle exec against another project would pick up pikuri's Gemfile.
See Bash.subprocess_env and BundlerEnv.
Timeout detection
GNU timeout exits 124 after SIGTERM, 137 after escalating to
SIGKILL; both are treated as "timed out". 125 is also accepted:
uutils-coreutils 0.2.2 (the Rust reimplementation on some distros)
mis-reports 125 instead of 124 when --kill-after is in play.
False-positive risk on real GNU coreutils is low (fixed, well-formed
argv). Caveat: 137 is ambiguous — the OOM-killer also exits 137; v1
accepts the mis-classification (the observation says "sent SIGTERM, then
SIGKILL" regardless).
Output handling
Combined stdout+stderr (popen2e), head+tail truncated at OUTPUT_HEAD + OUTPUT_TAIL bytes with a marker reporting bytes-omitted and total — the model needs the scale to decide whether to re-run with +head+/+grep+.
Backgrounded subprocesses
Plain cmd & does NOT detach — the child inherits our combined-output
pipe, so Subprocess#wait blocks on io.read until it exits. The
model must redirect fds 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.
Sharing
P_one_agent, but only because of the Workspace::Confirmer: with
a shared Confirmer::Terminal two agents fight over one human's
keystrokes. In substance this tool is stateless — every call spawns its
own subprocess, and the Workspace::Filesystem, Sandbox
and passive_detector it holds are immutable — so under
Confirmer::AutoApprove nothing stops one instance serving every agent.
Concurrency the commands create is not pikuri's to serialize: ten agents
running bundle install in one workspace will corrupt each other's work
exactly as ten shells would.
Defined Under Namespace
Modules: Sandbox, Tokenizer Classes: PassiveCommandDetector
Constant Summary collapse
- LOGGER =
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'- GIT_HARDENING =
Git config keys forced onto every git invocation, threaded in via git_hardening_delta. What each key does:
core.fsmonitor=false— the headline vector: a repo-localcore.fsmonitor = <cmd>runs<cmd>on every index refresh (plain +git status+/+diff+, no.gitattributesneeded).falseis git's default, so legitimate status/diff are unaffected.core.pager=cat— belt-and-suspenders; auto-paging is already inert under our non-TTY pipe, but a forcedcatmakes it a no-op regardless.
Why this exists — the config-execution vector that makes even a passive git command an unconfirmed RCE without it, the narrow diff-driver residual it deliberately does NOT close (+diff.external+ /
.gitattributes.textconv, uncloseable via environment), and the tie to PassiveCommandDetector'sallow_git:— lives inpikuri-code/DESIGN.md(Passive git needs Bash's hardening). [ ['core.fsmonitor', 'false'], ['core.pager', 'cat'] ].freeze
- 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.The
Avoid ... cat / rg / findbullet is load-bearing, not etiquette — a shell read skips thecat -nnumbering Workspace::Edit anchors on, the byte caps, the read-before-edit ledger and the path gate. (An outside-model review — Grok 4.6, 2026-08 — called the split the right bet; nothing tests that the model obeys the bullet.) <<~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. - IMPORTANT: Avoid using this tool to run `cat`, plain `head` or `tail`, `sed`, `awk`, `rg`, `find` or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user, respect the workspace and produce cleaner output. - 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. # Git - Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment. - Use the `gh` CLI (if available) for GitHub operations (PRs, issues, API). - Commit or push only when the user asks. DESC
Class Method Summary collapse
-
.run(filesystem:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE, passive_detector: nil) ⇒ String
Bounds-check, confirm, spawn, and render the observation.
Instance Method Summary collapse
- #initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil) ⇒ Bash constructor
Constructor Details
#initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil) ⇒ Bash
159 160 161 162 163 164 165 166 167 168 169 170 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 |
# File 'lib/pikuri/code/bash.rb', line 159 def initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil) Bash.send(:check_binaries!) # Without a sandbox, bash runs with pikuri's UID + filesystem view — # anything the user can read the LLM can read (+cat ~/.ssh/id_*+, +aws # configure list+), with the per-command Confirmer the only defense. Warn # only when the host opted out (or never opted in); Bubblewrap fixes it. 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(filesystem: filesystem, confirmer: confirmer, sandbox: sandbox, passive_detector: passive_detector, command: command, description: description, timeout: timeout) }, # Both inbound axes come from the *sandbox*, not the workspace: the # workspace governs what the LLM observes through the file tools, the # sandbox governs what bash sees. Scope the workspace to a vouched-for # repo and +cat ~/Downloads/*+ still works under a full-root bind. # # Note what this does *not* claim: +:human_reviewed+, though a # +Confirmer+ is right there. The human approves a *command* — a # program, not a payload. Behind a pipe, a heredoc, +$(…)+ or a script # file, the bytes that egress are computed at runtime and were never on # screen, so approving +curl+ approves an intent. That failure of the # payload-visibility clause is what keeps a coding agent over a private # repo loud, and it is deliberately a literal here rather than # something a rule engine could talk itself out of. # # The destination axis is left to derive: a shell picks its own host, # so a live leg here is always +:attacker_reachable+. trifecta_legs: Pikuri::Tool::TrifectaLegs.new( private: filesystem.private?, untrusted: sandbox.confined_to_workspace? && filesystem.trusted? ? :none : :hard, egress_payload_review: sandbox.egress? ? :unreviewed : :no_egress ) ) end |
Class Method Details
.run(filesystem:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE, passive_detector: nil) ⇒ String
Bounds-check, confirm, spawn, and render the observation. Returns
either "$ ...\n<out>\n\nexit status: N" on a normal exit, or
"Error: ..." on rejection / timeout / bad inputs.
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/pikuri/code/bash.rb', line 228 def self.run(filesystem:, confirmer:, command:, description:, timeout:, sandbox: Sandbox::NONE, passive_detector: nil) 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 # A command the detector deems passive (provably observe-only) runs # without a prompt; everything else is confirmed by the human. unless passive_detector&.passive?(command) request = compose_request(command: command, description: description, timeout: timeout) case confirmer.ask(request: request) in Pikuri::Workspace::Confirmer::Rejected(reason:) msg = +'Error: user declined the bash command.' msg << " Reason: #{reason}" if reason && !reason.empty? return msg in Pikuri::Workspace::Confirmer::Approved # fall through to execution (the command is non-editable, so the # approved detail is the same command we already hold) end end argv = sandbox.wrap([ 'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s", 'bash', '-c', command ]) result = Pikuri::Subprocess.spawn(*argv, chdir: filesystem.project_root.to_s, env: subprocess_env(filesystem)).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 |