Repository navigation
feat(sandbox): add sandboxed request to Python SDK [replica of harness-sdk PR 4756] #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: base-pr-4756
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
|
||||||||
| # 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`. | ||||||||
|
|
@@ -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. | ||||||||
|
|
||||||||
|
|
@@ -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 | ||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] |
||||||||
| 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; " | ||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Important] |
||||||||
| "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 | ||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] |
||||||||
| # 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, | ||||||||
| ) | ||||||||
There was a problem hiding this comment.
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'sRaises:section only lists network/transport errors and themax_bytesoverflow. The actual implementation also raisesSandboxHttpErrorfor 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 thePosixShellSandbox.requestdocstring), e.g. "SandboxHttpError: On invalid method/URL/headers, network/transport errors, or when the response body exceedsmax_bytes."