Spaces:
Running
Running
File size: 13,509 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 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 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 214 215 216 217 218 219 220 221 222 223 224 225 226 227 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 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | """Bash execution tool β fail-closed E2B, container, or bubblewrap sandbox."""
from __future__ import annotations
import logging
import os
from frontier_agent.core.execution_context import get_current_tool_budget
from frontier_agent.core.tool import tool
# Re-exported for backward compatibility: callers/tests import these from
# ``plugins.tools.bash``. The implementation now lives in ``_bash_policy``.
from plugins.tools._bash_policy import (
BashCommandAssessment,
assess_bash_command,
)
from plugins.tools._deliverable_policy import bash_output_write_error
from plugins.tools._net_guard import ensure_guard_file, guard_env_prefix
from plugins.tools._sandbox import aget_sandbox, arun_sandbox_cmd
logger = logging.getLogger(__name__)
# (requested, budget) pairs already warned about β see _warn_clamped_override.
_CLAMP_WARNED: set[tuple[int, int]] = set()
_MAX_OUTPUT_CHARS = 10_000
# Fallback deadline for a bash call made OUTSIDE the agent loop (a script, a
# test, a direct ``bash.ainvoke``). Inside the loop the configured
# ``tool_timeout_s`` wins β see _resolve_timeout.
_DEFAULT_BASH_TIMEOUT = 300
_DEFAULT_FILE_MAX_BYTES = 256 * 1024 * 1024
# Public so consumers (e.g. the report post-processor, sibling
# ``run_python_code.py``) can split stdout/stderr blocks deterministically
# without re-typing the literal β silent drift here would degrade their
# splitting back to "treat the whole blob as stdout".
BASH_STDERR_SEPARATOR = "\n--- stderr ---\n"
__all__ = ["BASH_STDERR_SEPARATOR", "BashCommandAssessment", "assess_bash_command", "bash"]
# 137 = 128+SIGKILL (the kernel OOM killer, or the watchdog's disposal step);
# 133 = 128+SIGTRAP, which is how a memory failure surfaces under x86-64
# emulation. MemoryError is the clean in-process case the memory cap produces.
# "[memory limit]" is the note _sandbox synthesizes when a per-exec cgroup
# group-killed the command (the kill itself leaves no output at all).
_OOM_MARKERS = ("MemoryError", "Cannot allocate memory", "std::bad_alloc",
"[memory guard]", "[memory limit]", "Killed")
_OOM_EXIT_CODES = (137, 133, -9)
def _looks_out_of_memory(stderr: str, exit_code: int | None) -> bool:
"""Whether this FAILURE was about memory rather than logic.
Takes the RAW stderr, not the rendered tool output. Recovering stderr by
splitting the rendered text on ``BASH_STDERR_SEPARATOR`` misses the most
common case there is: the separator is only inserted when stdout is
non-empty, and a Python ``MemoryError`` prints nothing to stdout β so the
incident's own tool result came back with no hint attached at all.
Two guards against telling a successful command it ran out of memory, which
would be worse than saying nothing β the model would "fix" working code:
* A zero exit is never a memory failure, whatever the text says. ``grep
MemoryError app.log`` succeeds and prints the marker; so does ``cat`` of a
traceback someone committed.
* Markers are matched against stderr only. A memory failure reports itself
there; stdout is data the command chose to print. Exit codes are still
authoritative on their own, since a SIGKILLed process prints nothing.
"""
if exit_code in _OOM_EXIT_CODES:
return True
if not exit_code: # 0 or None β not a failure at all
return False
return any(marker in stderr for marker in _OOM_MARKERS)
def _file_size_limit_prefix() -> str:
"""POSIX-shell file-size rlimit for direct curl/wget and other children.
``ulimit -f`` takes a block count whose size depends on the shell: bash
scales it by 1024, while POSIX shells (dash/ash/zsh β what ``shell=True``
gives us on the ``CurrentSandbox`` container path) scale it by 512. Picking
one unit would silently halve or double the intended cap depending on the
backend, so branch on ``$BASH_VERSION`` and emit the matching block count.
``2>/dev/null`` matches ``_ulimit_cap``: a shell that refuses to lower the
limit must not spray stderr into the model's tool output.
"""
raw = (os.environ.get("BASH_FILE_MAX_BYTES") or "").strip()
try:
max_bytes = int(raw) if raw else _DEFAULT_FILE_MAX_BYTES
except ValueError:
max_bytes = _DEFAULT_FILE_MAX_BYTES
if max_bytes <= 0:
return ""
kib_blocks = (max_bytes + 1023) // 1024
posix_blocks = (max_bytes + 511) // 512
return (
f'if [ -n "$BASH_VERSION" ]; then ulimit -f {kib_blocks} 2>/dev/null; '
f"else ulimit -f {posix_blocks} 2>/dev/null; fi; "
)
def _resolve_timeout() -> int:
"""Seconds this command may run.
Precedence, highest first:
1. ``BASH_TIMEOUT`` in the environment β an explicit operator override,
read per call rather than at import so exporting it from a launcher
still takes effect and tests can set it.
2. The agent loop's configured budget for this tool call, i.e. the
profile's ``tool_timeout_s``.
3. :data:`_DEFAULT_BASH_TIMEOUT`, for calls made outside the loop.
The loop budget is a CEILING on (1): ``execute_tools`` cancels the call at
the budget plus its own grace, so a larger override would only replace the
structured timeout message below with a bare "tool timed out" β the model
would lose the recovery hint and still lose the command. It is clamped, and
the clamp is logged once so a misconfiguration is visible without spamming
a long run.
This function is why ``tool_timeout_s`` reaches bash at all: the deadline
used to be a module constant frozen at import, so a profile asking for 1800s
still got 300s and every long compile/simulation died at 5 minutes (#53).
"""
budget = get_current_tool_budget()
override = os.environ.get("BASH_TIMEOUT", "").strip()
if override:
try:
requested = int(float(override))
except ValueError:
requested = 0
if requested > 0:
if budget is not None and requested > int(budget):
_warn_clamped_override(requested, int(budget))
return int(budget)
return requested
if budget is not None:
return max(int(budget), 1)
return _DEFAULT_BASH_TIMEOUT
def _warn_clamped_override(requested: int, budget: int) -> None:
"""Log a clamped ``BASH_TIMEOUT`` once per (requested, budget) pair."""
key = (requested, budget)
if key in _CLAMP_WARNED:
return
_CLAMP_WARNED.add(key)
logger.warning(
"BASH_TIMEOUT=%ss exceeds the loop's tool_timeout_s budget of %ss and was "
"clamped; raise tool_timeout_s in the active profile to actually grant "
"longer commands.",
requested, budget,
)
# ββ Tool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@tool
async def bash(command: str, description: str = "") -> str:
"""Execute a bash command in an isolated E2B or bubblewrap sandbox.
Use this for: Python code execution (with any packages like matplotlib,
numpy, pandas), shell commands, file operations, and computation.
To run Python, pipe a script to ``python3`` via a heredoc β this is the
preferred way and avoids the quoting/escaping pain of ``python3 -c`` while
supporting full multi-line scripts:
python3 <<'PY'
import pandas as pd
df = pd.read_csv("/inputs/data.csv")
print(df.describe())
PY
Reserve ``python3 -c "..."`` for trivial one-liners.
Args:
command: The bash command to execute. For Python, prefer a
``python3 <<'PY' ... PY`` heredoc (multi-line) over ``python3 -c``.
description: Optional description of what this command does (for logging).
Each command runs with a per-process memory limit (1 GB by default; see
SANDBOX_CONTAINER_MEM_MB). Processing a large dataset by loading it whole
will hit it β read in chunks or stream instead. Hitting the limit raises
MemoryError in the command, not an infrastructure failure.
Genuinely long jobs (compiles, simulations, training runs) are fine to run
in one call β the per-command deadline is the session's configured tool
timeout, not a few minutes. Do not pre-emptively split work into chunks to
stay under a guessed limit. If a job may exceed the deadline, launch it with
nohup, redirect its output to a file, and poll that file on later calls; the
timeout message says so too if you hit it.
Returns:
Command output (stdout + stderr), or error message.
"""
if not command or not command.strip():
return "Error: empty command."
deliverable_error = bash_output_write_error(command)
if deliverable_error:
return f"Error: command denied. {deliverable_error}"
assessment = assess_bash_command(command)
if assessment.level == "deny":
return f"Error: command denied. {assessment.reason}"
if assessment.level == "confirm":
# In SWE benchmark mode (per-task sandbox), downgrade to audit
from plugins.tools._sandbox import _task_sandbox
if _task_sandbox.get(None) is not None:
assessment = BashCommandAssessment(level="audit", reason=assessment.reason)
else:
return f"Error: command requires confirmation. {assessment.reason}"
try:
sandbox = await aget_sandbox()
except RuntimeError as e:
return f"Error: {e}"
# Arm the socket-level download cap for any python the command spawns
# (``python3 -c``, pip, scripts) β env exports propagate to children.
# ``export`` (not the ``VAR=x cmd`` prefix form) so compound commands
# (``cd x && python3 ...``) are covered too. See _net_guard.py.
await ensure_guard_file(sandbox)
guard_env = guard_env_prefix()
guarded_command = (
f"export {guard_env.rstrip()}; {command}" if guard_env else command
)
exec_command = f"{_file_size_limit_prefix()}{guarded_command}"
# Resolved per call, not at import: the deadline belongs to the running
# loop's profile, and a module constant made ``tool_timeout_s`` a no-op.
timeout_s = _resolve_timeout()
try:
result = await arun_sandbox_cmd(
sandbox,
exec_command,
timeout=timeout_s,
# Current task workspaces intentionally support network-backed
# research and document retrieval. Resource-safe document
# downloads should use download_file; bash networking remains
# available for APIs and existing skills.
allow_net=True,
)
output = ""
if result.stdout:
output += result.stdout
if result.stderr:
if output:
output += BASH_STDERR_SEPARATOR
output += result.stderr
if not output:
output = "(no output)"
if result.exit_code == -1:
# E2B's "died without a normal exit status" code (OOM kill /
# sandbox-side failure) β usually paired with empty output.
output = (
"[Exit code -1 β sandbox process died unexpectedly, likely "
f"out-of-memory or a sandbox-side failure]\n{output}"
)
elif result.exit_code != 0:
output = f"[Exit code {result.exit_code}]\n{output}"
# Turn a memory failure into an actionable instruction. The model reads
# this text and nothing else, so a bare MemoryError traceback leaves
# "retry the same thing" looking reasonable. See WORKER_OOM_HARDENING
# (P0-2).
if _looks_out_of_memory(result.stderr or "", result.exit_code):
output += (
"\n\n[hint] This failed on MEMORY, not on logic β retrying the same "
"command will fail the same way. Reduce peak memory instead: read the "
"input in chunks or line by line rather than loading it whole, write "
"intermediate results to a file under /workspace instead of keeping "
"them in a list, and process one item at a time."
)
if assessment.level == "audit":
output = f"[Audit] {assessment.reason}\n{output}"
# Mask host filesystem paths in output
from plugins.tools._paths import mask_paths_in_output
output = mask_paths_in_output(output)
# Apply overflow handling (truncate + save full to disk if needed)
from plugins.tools._overflow import maybe_overflow
return maybe_overflow("bash", output)
except TimeoutError:
return (
f"Error: Command timed out after {timeout_s} seconds.\n\n"
"[hint] The command or script took too long and was interrupted. "
"Do NOT retry the exact same long-running script. "
"If the work genuinely needs more than this limit, start it in the "
"background instead of shortening it β redirect its output to a file "
"under /workspace, launch it with nohup, and poll that file on later "
"calls. Otherwise switch to a faster method (alternative API, smaller "
"data fetch, or pre-calculated data)."
)
except Exception as e:
logger.warning("bash tool error: %s", e)
return f"Error: {type(e).__name__}: {e}"
|