Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion strands-py/src/strands/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,19 @@ async def execute_streaming(self, command, *, timeout=None, cwd=None, env=None,

from .base import Sandbox
from .constants import LANGUAGE_PATTERN
from .errors import SandboxHttpError
from .posix_shell import PosixShellSandbox
from .types import ExecutionResult, FileInfo, OutputFile, StreamChunk, StreamType
from .types import ExecutionResult, FileInfo, HttpResult, OutputFile, StreamChunk, StreamType

__all__ = [
"ExecutionResult",
"FileInfo",
"HttpResult",
"LANGUAGE_PATTERN",
"OutputFile",
"PosixShellSandbox",
"Sandbox",
"SandboxHttpError",
"StreamChunk",
"StreamType",
]
40 changes: 39 additions & 1 deletion strands-py/src/strands/sandbox/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from typing import Any

from ..types.tools import AgentTool
from .types import ExecutionResult, FileInfo, StreamChunk
from .types import ExecutionResult, FileInfo, HttpResult, StreamChunk

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -200,6 +200,44 @@ async def list_files(self, path: str, **kwargs: Any) -> list[FileInfo]:
"""
...

async def request(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: bytes | str | None = None,
timeout: float | None = None,
max_bytes: int | None = None,
**kwargs: Any,
) -> HttpResult:
"""Make an HTTP request inside the sandbox.

Works with any HTTP method. Optional because not every sandbox needs
HTTP access.

Args:
method: HTTP method (e.g. ``GET``, ``POST``, ``PUT``, ``DELETE``,
``PATCH``, ``HEAD``, ``OPTIONS``).
url: The http(s) URL to request.
headers: Request headers.
body: Request body. ``str`` is sent as UTF-8; ``bytes`` is sent as-is.
``None`` sends no body.
timeout: Maximum execution time in seconds. ``None`` means no timeout.
max_bytes: Bound the response body to this many bytes. ``None`` means
no limit. A response exceeding the cap raises
:class:`SandboxHttpError` rather than returning a truncated body.
**kwargs: Additional keyword arguments for forward compatibility.

Returns:
An :class:`HttpResult`. HTTP error statuses (4xx/5xx) are returned in ``status`` rather than raised.

Raises:
SandboxHttpError: On network/transport errors, or when the response
body exceeds ``max_bytes``.
"""

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important]
Issue: The base request() docstring's Raises: section only lists network/transport errors and the max_bytes overflow. The actual implementation also raises SandboxHttpError for input-validation failures (unsupported method, non-http(s) scheme, missing host, URL chars outside the RFC 3986 allowlist, and header name/value injection). Callers reading the public API contract won't know these synchronous validation errors share the same exception type.
Suggestion: Document the validation failures in the Raises: section here (and mirror in the PosixShellSandbox.request docstring), e.g. "SandboxHttpError: On invalid method/URL/headers, network/transport errors, or when the response body exceeds max_bytes."

raise NotImplementedError(f"{type(self).__name__} does not implement request()")

# ---- Tool vending ----

def get_tools(self) -> list[AgentTool]:
Expand Down
4 changes: 4 additions & 0 deletions strands-py/src/strands/sandbox/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ def __init__(self, seconds: float | None, stdout: str = "", stderr: str = "") ->
self.stderr = stderr


class SandboxHttpError(RuntimeError):
"""Raised when :meth:`~strands.sandbox.base.Sandbox.request` fails."""


class SandboxPathNotFoundError(FileNotFoundError):
"""Raised by :meth:`~strands.sandbox.base.Sandbox.list_files` when the path does not exist.

Expand Down
244 changes: 242 additions & 2 deletions strands-py/src/strands/sandbox/posix_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,48 @@
"""

import base64
import contextlib
import logging
import re
import shlex
import uuid
from abc import ABC
from collections.abc import AsyncGenerator
from typing import Any
from urllib.parse import urlparse

from .base import Sandbox
from .constants import ENV_KEY_PATTERN, LANGUAGE_PATTERN
from .errors import SandboxPathNotFoundError
from .types import ExecutionResult, FileInfo, StreamChunk
from .errors import SandboxHttpError, SandboxPathNotFoundError, SandboxTimeoutError
from .types import ExecutionResult, FileInfo, HttpResult, StreamChunk

logger = logging.getLogger(__name__)

# The characters RFC 3986 allows anywhere in a URL. Anything outside this
# set (whitespace, quotes, control characters, non-ASCII) is rejected before
# the URL reaches a shell command. Security-critical for the curl transport.
_URL_CHARS = re.compile(r"[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+")

# Anything outside this set (or a CR/LF/NUL in a value) could inject additional headers

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion]
Issue: Typo in the comment — "smuggle a second equest" should read "request".
Suggestion:

Suggested change
# Anything outside this set (or a CR/LF/NUL in a value) could inject additional headers
# Anything outside this set (or a CR/LF/NUL in a value) could inject additional headers
# or smuggle a second request, so header names are validated against this pattern.

# or smuggle a second equest, so header names are validated against this pattern.
_HEADER_NAME = re.compile(r"[!#$%&'*+\-.^_|~0-9A-Za-z]+")

# HTTP methods accepted by request(). An explicit allowlist (matching the
# http_request tool's HttpMethod) keeps the method safe to place in the curl
# command line and rejects anything unexpected outright.
_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"})

# curl's "Failure writing output" exit code. When a response is capped, the body
# is piped through ``head -c``; once ``head`` has read the cap it closes the pipe
# and curl aborts with this code — the signal that the body exceeded ``max_bytes``.
_CURL_WRITE_ERROR = 23

# Tags curl's final URL on stderr for the capped path, where stdout is reserved
# for the (binary) response body. Error text never contains this literal, so the
# resolved URL can be recovered unambiguously.
_URL_MARKER = "STRANDS_URL:"
_URL_MARKER_RE = re.compile(re.escape(_URL_MARKER) + r"(\S*)")


def validate_env_keys(env: dict[str, str]) -> None:
"""Validate environment variable names against :data:`ENV_KEY_PATTERN`.
Expand Down Expand Up @@ -74,6 +102,71 @@ def _eof_marker() -> str:
return f"STRANDS_EOF_{uuid.uuid4().hex[:16]}"


def _parse_status_and_headers(raw: str) -> tuple[int, str, dict[str, str]]:
r"""Parse a curl ``-D`` header dump into ``(status, status_text, headers)``.

Curl dumps headers for every hop in a redirect chain, separated by blank
lines. Only the final response is parsed so that stale headers from
intermediate redirects don't leak through.

Repeated headers (e.g. ``Set-Cookie``) are preserved by joining their values
with a newline, matching the ``http_request`` tool's handling. ``status`` is
``0`` and ``status_text`` is ``""`` when the dump has no parseable status line.
"""
# Split on blank lines, take the last non-empty block.
blocks = re.split(r"\n\s*\n", raw.replace("\r\n", "\n"))
last_block = ""
for block in reversed(blocks):
if block.strip():
last_block = block
break

status = 0
status_text = ""
headers: dict[str, str] = {}
for line in last_block.split("\n"):
line = line.strip()
if not line:
continue
if line.upper().startswith("HTTP/"):
# Status line, e.g. "HTTP/1.1 200 OK" or "HTTP/2 204".
parts = line.split(None, 2)
if len(parts) >= 2 and parts[1].isdigit():
status = int(parts[1])
status_text = parts[2].strip() if len(parts) == 3 else ""
continue
colon = line.find(":")
if colon < 1:
continue
name = line[:colon].strip().lower()
value = line[colon + 1 :].strip()
existing = headers.get(name)
headers[name] = value if existing is None else f"{existing}\n{value}"
return status, status_text, headers


def _resolve_capped_outcome(rc_text: str, stderr_text: str, fallback_exit: int, max_bytes: int | None, url: str) -> str:
"""Interpret a capped request's curl exit code and tagged stderr.

The pipeline's exit status is ``head``'s, so curl's own code is read from a file
(``fallback_exit`` if unreadable); the resolved URL and error text share the tagged stderr.

Returns the resolved URL on success.

Raises:
SandboxHttpError: When the body exceeded ``max_bytes`` (curl aborted
writing to the closed pipe) or the transfer otherwise failed.
"""
curl_rc = int(rc_text) if rc_text.isdigit() else fallback_exit
if curl_rc == _CURL_WRITE_ERROR:
raise SandboxHttpError(f"response body exceeded max_bytes ({max_bytes})")
if curl_rc != 0:
detail = _URL_MARKER_RE.sub("", stderr_text).strip()
raise SandboxHttpError(detail or f"curl exited with code {curl_rc}")
match = _URL_MARKER_RE.search(stderr_text)
return (match.group(1) if match else "") or url


class PosixShellSandbox(Sandbox, ABC):
"""Abstract sandbox that provides shell-based defaults for file and code operations.

Expand Down Expand Up @@ -239,3 +332,150 @@ async def list_files(self, path: str, **kwargs: Any) -> list[FileInfo]:
if name:
entries.append(FileInfo(name=name, is_dir=is_dir))
return entries

async def request(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: bytes | str | None = None,
timeout: float | None = None,
max_bytes: int | None = None,
**kwargs: Any,
) -> HttpResult:
"""Make an HTTP request with ``curl`` inside the sandbox.

Works with any HTTP method. HTTP error statuses (4xx/5xx) are returned
in :attr:`HttpResult.status` rather than raised; only transport-level

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion]
Issue: Temp files are created under the world-writable, shared /tmp with names derived from uuid4(). In a multi-tenant sandbox a pre-existing symlink at /tmp/strands-http-<tag> (or a race) could cause curl's -o/-D writes or the : > body_file truncation to land on an attacker-chosen path. The uuid makes collisions unlikely but doesn't defend against a planted symlink.
Suggestion: Consider writing to a per-request private directory created with restrictive perms (e.g. mktemp -d) rather than individual files in the /tmp root, so a planted symlink can't redirect the writes. If the sandbox's filesystem is already assumed single-tenant/isolated, a short note documenting that assumption would suffice.

failures (DNS, connection, timeout, size cap) raise
:class:`SandboxHttpError`. ``max_bytes`` bounds the download: the
transfer is stopped at the cap and a response exceeding it raises
:class:`SandboxHttpError` rather than returning a silently truncated body
(matching the ``web_fetch`` tool).
"""
method = method.strip().upper()
if method not in _ALLOWED_METHODS:
raise SandboxHttpError(f"Unsupported HTTP method {method!r}; expected one of {sorted(_ALLOWED_METHODS)}.")
url = url.strip()
if not _URL_CHARS.fullmatch(url):
raise SandboxHttpError(
"request URLs may only contain the characters RFC 3986 allows; "

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important]
Issue: When timeout is None, no --max-time (or --connect-timeout) is passed to curl and exec_timeout is also None, so a stalled connection or a slow-loris server can hang request() indefinitely with no way to recover. The default (most common) call omits timeout, so the default behavior is "no timeout at all".
Suggestion: Consider always passing a --connect-timeout (and/or a sane default overall timeout) even when timeout is None, so the common path is bounded against transport-level hangs. If an unbounded default is intentional, please document that explicitly in the request() docstring and the PR description.

"percent-encode spaces and non-ASCII characters (and punycode the host) and retry."
)
parts = urlparse(url)
if parts.scheme not in ("http", "https"):
raise SandboxHttpError(f"request only supports http(s) URLs, got {url!r}.")
if not parts.hostname:
raise SandboxHttpError(f"request URL has no host: {url!r}.")

tag = uuid.uuid4().hex
body_file = f"/tmp/strands-http-{tag}"
header_file = f"/tmp/strands-http-{tag}.headers"
data_file = f"/tmp/strands-http-{tag}.data"
rc_file = f"/tmp/strands-http-{tag}.rc"
err_file = f"/tmp/strands-http-{tag}.err"
body_quoted = shlex.quote(body_file)
header_quoted = shlex.quote(header_file)
data_quoted = shlex.quote(data_file)
rc_quoted = shlex.quote(rc_file)
err_quoted = shlex.quote(err_file)

# A HEAD response carries no body; use curl's --head so it doesn't block
# waiting for a body the server will never send.
is_head = method == "HEAD"

# Write the request body to a temp file and feed it via --data-binary so

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion]
Issue: write_file(data_file, ...) for the request body happens before the try/finally that performs the rm -f cleanup. If self.execute(cmd) is never reached but the data file was written (it isn't here since write precedes the try, but note the finally only runs for files created inside the try), the data file is still removed by the finally since its name is in the rm -f list — good. However, if write_file itself raises, no cleanup runs and the body file is already on disk.
Suggestion: Consider moving the body write_file inside the try block so the finally cleanup also covers a partially-written data file on write failure.

# arbitrary bytes never touch the shell command line.
if body is not None:
await self.write_file(data_file, body.encode("utf-8") if isinstance(body, str) else body)

opts = ""
if timeout is not None:
opts += f" --max-time {timeout}"
if headers:
for n, v in headers.items():
if not _HEADER_NAME.fullmatch(n) or re.search(r"[\r\n\0]", v):
raise SandboxHttpError(
f"invalid header {n!r}: name must be an RFC 7230 token and value may not contain CR/LF/NUL"
)
# `-H "name;"` sends an empty-valued header rather than omitting it.
opts += f" -H {shlex.quote(f'{n};' if v == '' else f'{n}: {v}')}"
if body is not None:
opts += f" --data-binary @{data_quoted}"

# Only force the method where curl can't infer it. ``-X POST`` with
# ``-L`` replays a bodiless POST through 302/303 redirects (curl keeps the
# method but drops the body); omitting ``-X`` lets curl switch to GET on
# those redirects like browsers/httpx do. GET and a body-carrying POST are
# curl's defaults, so they need no ``-X``.
if is_head:
method_opt = "--head"
elif method == "GET" or (method == "POST" and body is not None):
method_opt = ""
else:
method_opt = f"-X {method}"

# Cap the response body (not meaningful for HEAD, which carries none).
cap = None if is_head else max_bytes

base_curl = (
f"curl -sSL -g {method_opt} --proto '=http,https' --proto-redir '=http,https'{opts} -D {header_quoted}"
)
if cap is not None:
# Bound the download: pipe the body through ``head -c`` so curl is stopped at the cap (head closes
# the pipe, curl exits 23) rather than buffering the whole response; the pipeline's status is head's,
# so curl's code goes to a file and the resolved URL is tagged onto stderr to keep stdout pure body.
cmd = (
f"{{ {base_curl} -o - -w '%{{stderr}}{_URL_MARKER}%{{url_effective}}'"
f" -- {shlex.quote(url)}; echo $? > {rc_quoted}; }}"
f" 2> {err_quoted} | head -c {cap + 1} > {body_quoted}"
)
else:
out_target = "/dev/null" if is_head else body_quoted
cmd = f"{base_curl} -o {out_target} -w '%{{url_effective}}' -- {shlex.quote(url)}"
if not is_head:
# curl omits the -o file for a bodyless response (e.g. 304 Not Modified);
# pre-create it so the read-back always finds a (possibly empty) file.
cmd = f": > {body_quoted} && {cmd}"

# Give curl a few extra seconds beyond its own --max-time so the
# sandbox kills it only if curl itself hangs.
exec_timeout = (timeout + 5) if timeout is not None else None

try:
try:
result = await self.execute(cmd, timeout=exec_timeout, **kwargs)
except SandboxTimeoutError as exc:
raise SandboxHttpError(f"request timed out after {timeout}s") from exc

if cap is not None:
# The pipeline's status is ``head``'s, so curl's own code is read from its file;
# the resolved URL and any error text come from the tagged stderr.
rc_text = (await self.read_file(rc_file)).decode("utf-8", errors="replace").strip()
err_text = (await self.read_file(err_file)).decode("utf-8", errors="replace")
resolved_url = _resolve_capped_outcome(rc_text, err_text, result.exit_code, cap, url)
else:
if result.exit_code != 0:
raise SandboxHttpError(result.stderr.strip() or f"curl exited with code {result.exit_code}")
resolved_url = result.stdout.strip() or url

raw_headers = (await self.read_file(header_file)).decode("utf-8", errors="replace")
status, status_text, response_headers = _parse_status_and_headers(raw_headers)
response_body = b"" if is_head else await self.read_file(body_file)
if cap is not None and len(response_body) > cap:
raise SandboxHttpError(f"response body exceeded max_bytes ({cap})")
finally:
with contextlib.suppress(Exception):
await self.execute(
f"rm -f {body_quoted} {header_quoted} {data_quoted} {rc_quoted} {err_quoted}",
timeout=10,
)

return HttpResult(
status=status,
status_text=status_text,
resolved_url=resolved_url,
headers=response_headers,
body=response_body,
)
21 changes: 21 additions & 0 deletions strands-py/src/strands/sandbox/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,24 @@ class ExecutionResult:
stdout: str
stderr: str
output_files: list[OutputFile] = field(default_factory=list)


@dataclass
class HttpResult:
"""Result of an HTTP request inside a sandbox.

Attributes:
status: HTTP status code of the final response.
status_text: HTTP reason phrase of the final response (may be empty when
the server omits it).
resolved_url: Final URL after redirects.
headers: Response headers with lowercased keys. Repeated headers (e.g.
``Set-Cookie``) are preserved, joined with a newline.
body: Raw response body bytes.
"""

status: int
status_text: str
resolved_url: str
headers: dict[str, str]
body: bytes
Loading
Loading