apodex/frontier-agent-demo
14
1"""Bash execution tool — fail-closed E2B, container, or bubblewrap sandbox."""2 3from __future__ import annotations4 5import logging6import os7 8from frontier_agent.core.execution_context import get_current_tool_budget9from frontier_agent.core.tool import tool10 11# Re-exported for backward compatibility: callers/tests import these from12# ``plugins.tools.bash``. The implementation now lives in ``_bash_policy``.13from plugins.tools._bash_policy import (14 BashCommandAssessment,15 assess_bash_command,16)17from plugins.tools._deliverable_policy import bash_output_write_error18from plugins.tools._net_guard import ensure_guard_file, guard_env_prefix19from plugins.tools._sandbox import aget_sandbox, arun_sandbox_cmd20 21logger = logging.getLogger(__name__)22 23# (requested, budget) pairs already warned about — see _warn_clamped_override.24_CLAMP_WARNED: set[tuple[int, int]] = set()25 26_MAX_OUTPUT_CHARS = 10_00027# Fallback deadline for a bash call made OUTSIDE the agent loop (a script, a28# test, a direct ``bash.ainvoke``). Inside the loop the configured29# ``tool_timeout_s`` wins — see _resolve_timeout.30_DEFAULT_BASH_TIMEOUT = 30031_DEFAULT_FILE_MAX_BYTES = 256 * 1024 * 102432 33# Public so consumers (e.g. the report post-processor, sibling34# ``run_python_code.py``) can split stdout/stderr blocks deterministically35# without re-typing the literal — silent drift here would degrade their36# splitting back to "treat the whole blob as stdout".37BASH_STDERR_SEPARATOR = "\n--- stderr ---\n"38 39__all__ = ["BASH_STDERR_SEPARATOR", "BashCommandAssessment", "assess_bash_command", "bash"]40 41 42# 137 = 128+SIGKILL (the kernel OOM killer, or the watchdog's disposal step);43# 133 = 128+SIGTRAP, which is how a memory failure surfaces under x86-6444# emulation. MemoryError is the clean in-process case the memory cap produces.45# "[memory limit]" is the note _sandbox synthesizes when a per-exec cgroup46# group-killed the command (the kill itself leaves no output at all).47_OOM_MARKERS = ("MemoryError", "Cannot allocate memory", "std::bad_alloc",48 "[memory guard]", "[memory limit]", "Killed")49_OOM_EXIT_CODES = (137, 133, -9)50 51 52def _looks_out_of_memory(stderr: str, exit_code: int | None) -> bool:53 """Whether this FAILURE was about memory rather than logic.54 55 Takes the RAW stderr, not the rendered tool output. Recovering stderr by56 splitting the rendered text on ``BASH_STDERR_SEPARATOR`` misses the most57 common case there is: the separator is only inserted when stdout is58 non-empty, and a Python ``MemoryError`` prints nothing to stdout — so the59 incident's own tool result came back with no hint attached at all.60 61 Two guards against telling a successful command it ran out of memory, which62 would be worse than saying nothing — the model would "fix" working code:63 64 * A zero exit is never a memory failure, whatever the text says. ``grep65 MemoryError app.log`` succeeds and prints the marker; so does ``cat`` of a66 traceback someone committed.67 * Markers are matched against stderr only. A memory failure reports itself68 there; stdout is data the command chose to print. Exit codes are still69 authoritative on their own, since a SIGKILLed process prints nothing.70 """71 if exit_code in _OOM_EXIT_CODES:72 return True73 if not exit_code: # 0 or None → not a failure at all74 return False75 return any(marker in stderr for marker in _OOM_MARKERS)76 77 78def _file_size_limit_prefix() -> str:79 """POSIX-shell file-size rlimit for direct curl/wget and other children.80 81 ``ulimit -f`` takes a block count whose size depends on the shell: bash82 scales it by 1024, while POSIX shells (dash/ash/zsh — what ``shell=True``83 gives us on the ``CurrentSandbox`` container path) scale it by 512. Picking84 one unit would silently halve or double the intended cap depending on the85 backend, so branch on ``$BASH_VERSION`` and emit the matching block count.86 ``2>/dev/null`` matches ``_ulimit_cap``: a shell that refuses to lower the87 limit must not spray stderr into the model's tool output.88 """89 raw = (os.environ.get("BASH_FILE_MAX_BYTES") or "").strip()90 try:91 max_bytes = int(raw) if raw else _DEFAULT_FILE_MAX_BYTES92 except ValueError:93 max_bytes = _DEFAULT_FILE_MAX_BYTES94 if max_bytes <= 0:95 return ""96 kib_blocks = (max_bytes + 1023) // 102497 posix_blocks = (max_bytes + 511) // 51298 return (99 f'if [ -n "$BASH_VERSION" ]; then ulimit -f {kib_blocks} 2>/dev/null; '100 f"else ulimit -f {posix_blocks} 2>/dev/null; fi; "101 )102 103 104def _resolve_timeout() -> int:105 """Seconds this command may run.106 107 Precedence, highest first:108 109 1. ``BASH_TIMEOUT`` in the environment — an explicit operator override,110 read per call rather than at import so exporting it from a launcher111 still takes effect and tests can set it.112 2. The agent loop's configured budget for this tool call, i.e. the113 profile's ``tool_timeout_s``.114 3. :data:`_DEFAULT_BASH_TIMEOUT`, for calls made outside the loop.115 116 The loop budget is a CEILING on (1): ``execute_tools`` cancels the call at117 the budget plus its own grace, so a larger override would only replace the118 structured timeout message below with a bare "tool timed out" — the model119 would lose the recovery hint and still lose the command. It is clamped, and120 the clamp is logged once so a misconfiguration is visible without spamming121 a long run.122 123 This function is why ``tool_timeout_s`` reaches bash at all: the deadline124 used to be a module constant frozen at import, so a profile asking for 1800s125 still got 300s and every long compile/simulation died at 5 minutes (#53).126 """127 budget = get_current_tool_budget()128 override = os.environ.get("BASH_TIMEOUT", "").strip()129 if override:130 try:131 requested = int(float(override))132 except ValueError:133 requested = 0134 if requested > 0:135 if budget is not None and requested > int(budget):136 _warn_clamped_override(requested, int(budget))137 return int(budget)138 return requested139 if budget is not None:140 return max(int(budget), 1)141 return _DEFAULT_BASH_TIMEOUT142 143 144def _warn_clamped_override(requested: int, budget: int) -> None:145 """Log a clamped ``BASH_TIMEOUT`` once per (requested, budget) pair."""146 key = (requested, budget)147 if key in _CLAMP_WARNED:148 return149 _CLAMP_WARNED.add(key)150 logger.warning(151 "BASH_TIMEOUT=%ss exceeds the loop's tool_timeout_s budget of %ss and was "152 "clamped; raise tool_timeout_s in the active profile to actually grant "153 "longer commands.",154 requested, budget,155 )156 157 158# ── Tool ────────────────────────────────────────────────────────────────159 160 161@tool162async def bash(command: str, description: str = "") -> str:163 """Execute a bash command in an isolated E2B or bubblewrap sandbox.164 165 Use this for: Python code execution (with any packages like matplotlib,166 numpy, pandas), shell commands, file operations, and computation.167 168 To run Python, pipe a script to ``python3`` via a heredoc — this is the169 preferred way and avoids the quoting/escaping pain of ``python3 -c`` while170 supporting full multi-line scripts:171 172 python3 <<'PY'173 import pandas as pd174 df = pd.read_csv("/inputs/data.csv")175 print(df.describe())176 PY177 178 Reserve ``python3 -c "..."`` for trivial one-liners.179 180 Args:181 command: The bash command to execute. For Python, prefer a182 ``python3 <<'PY' ... PY`` heredoc (multi-line) over ``python3 -c``.183 description: Optional description of what this command does (for logging).184 185 Each command runs with a per-process memory limit (1 GB by default; see186 SANDBOX_CONTAINER_MEM_MB). Processing a large dataset by loading it whole187 will hit it — read in chunks or stream instead. Hitting the limit raises188 MemoryError in the command, not an infrastructure failure.189 190 Genuinely long jobs (compiles, simulations, training runs) are fine to run191 in one call — the per-command deadline is the session's configured tool192 timeout, not a few minutes. Do not pre-emptively split work into chunks to193 stay under a guessed limit. If a job may exceed the deadline, launch it with194 nohup, redirect its output to a file, and poll that file on later calls; the195 timeout message says so too if you hit it.196 197 Returns:198 Command output (stdout + stderr), or error message.199 """200 if not command or not command.strip():201 return "Error: empty command."202 203 deliverable_error = bash_output_write_error(command)204 if deliverable_error:205 return f"Error: command denied. {deliverable_error}"206 207 assessment = assess_bash_command(command)208 if assessment.level == "deny":209 return f"Error: command denied. {assessment.reason}"210 if assessment.level == "confirm":211 # In SWE benchmark mode (per-task sandbox), downgrade to audit212 from plugins.tools._sandbox import _task_sandbox213 if _task_sandbox.get(None) is not None:214 assessment = BashCommandAssessment(level="audit", reason=assessment.reason)215 else:216 return f"Error: command requires confirmation. {assessment.reason}"217 218 try:219 sandbox = await aget_sandbox()220 except RuntimeError as e:221 return f"Error: {e}"222 223 # Arm the socket-level download cap for any python the command spawns224 # (``python3 -c``, pip, scripts) — env exports propagate to children.225 # ``export`` (not the ``VAR=x cmd`` prefix form) so compound commands226 # (``cd x && python3 ...``) are covered too. See _net_guard.py.227 await ensure_guard_file(sandbox)228 guard_env = guard_env_prefix()229 guarded_command = (230 f"export {guard_env.rstrip()}; {command}" if guard_env else command231 )232 exec_command = f"{_file_size_limit_prefix()}{guarded_command}"233 234 # Resolved per call, not at import: the deadline belongs to the running235 # loop's profile, and a module constant made ``tool_timeout_s`` a no-op.236 timeout_s = _resolve_timeout()237 238 try:239 result = await arun_sandbox_cmd(240 sandbox,241 exec_command,242 timeout=timeout_s,243 # Current task workspaces intentionally support network-backed244 # research and document retrieval. Resource-safe document245 # downloads should use download_file; bash networking remains246 # available for APIs and existing skills.247 allow_net=True,248 )249 250 output = ""251 if result.stdout:252 output += result.stdout253 if result.stderr:254 if output:255 output += BASH_STDERR_SEPARATOR256 output += result.stderr257 258 if not output:259 output = "(no output)"260 261 if result.exit_code == -1:262 # E2B's "died without a normal exit status" code (OOM kill /263 # sandbox-side failure) — usually paired with empty output.264 output = (265 "[Exit code -1 — sandbox process died unexpectedly, likely "266 f"out-of-memory or a sandbox-side failure]\n{output}"267 )268 elif result.exit_code != 0:269 output = f"[Exit code {result.exit_code}]\n{output}"270 271 # Turn a memory failure into an actionable instruction. The model reads272 # this text and nothing else, so a bare MemoryError traceback leaves273 # "retry the same thing" looking reasonable. See WORKER_OOM_HARDENING274 # (P0-2).275 if _looks_out_of_memory(result.stderr or "", result.exit_code):276 output += (277 "\n\n[hint] This failed on MEMORY, not on logic — retrying the same "278 "command will fail the same way. Reduce peak memory instead: read the "279 "input in chunks or line by line rather than loading it whole, write "280 "intermediate results to a file under /workspace instead of keeping "281 "them in a list, and process one item at a time."282 )283 284 if assessment.level == "audit":285 output = f"[Audit] {assessment.reason}\n{output}"286 287 # Mask host filesystem paths in output288 from plugins.tools._paths import mask_paths_in_output289 output = mask_paths_in_output(output)290 291 # Apply overflow handling (truncate + save full to disk if needed)292 from plugins.tools._overflow import maybe_overflow293 return maybe_overflow("bash", output)294 295 except TimeoutError:296 return (297 f"Error: Command timed out after {timeout_s} seconds.\n\n"298 "[hint] The command or script took too long and was interrupted. "299 "Do NOT retry the exact same long-running script. "300 "If the work genuinely needs more than this limit, start it in the "301 "background instead of shortening it — redirect its output to a file "302 "under /workspace, launch it with nohup, and poll that file on later "303 "calls. Otherwise switch to a faster method (alternative API, smaller "304 "data fetch, or pre-calculated data)."305 )306 except Exception as e:307 logger.warning("bash tool error: %s", e)308 return f"Error: {type(e).__name__}: {e}"309 