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}"