CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
bash.py309 linesDownload Raw Back to tools
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