From 914207ffb372b9e77e9cd512045ab54d0edffbcc Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:57:42 -0700 Subject: [PATCH 01/14] feat(runtime): resolve sandbox ports over a shared Docker network (#775) Co-authored-by: Ahmed Allam --- strix/runtime/docker_client.py | 70 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 2321fa50..03010a12 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -24,18 +24,22 @@ from __future__ import annotations import contextlib import logging +import os import uuid -from typing import Any +from typing import Any, cast +from agents.sandbox.errors import ExposedPortUnavailableError from agents.sandbox.manifest import Manifest from agents.sandbox.sandboxes.docker import ( DockerSandboxClient, + DockerSandboxSession, _build_docker_volume_mounts, _docker_port_key, _manifest_requires_fuse, _manifest_requires_sys_admin, ) from agents.sandbox.session.sandbox_session import SandboxSession +from agents.sandbox.types import ExposedPortEndpoint from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore] from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore] from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore] @@ -46,6 +50,59 @@ from requests.exceptions import RequestException logger = logging.getLogger(__name__) +_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK" + + +def _sandbox_network() -> str | None: + value = os.environ.get(_SANDBOX_NETWORK_ENV, "").strip() + return value or None + + +def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None: + network = _sandbox_network() + if network: + create_kwargs["network"] = network + create_kwargs.pop("ports", None) + + +class StrixDockerSandboxSession(DockerSandboxSession): + sandbox_network: str = "" + + async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: + try: + self._container.reload() + except docker_errors.APIError as e: + raise ExposedPortUnavailableError( + port=port, + exposed_ports=self.state.exposed_ports, + reason="backend_unavailable", + context={ + "backend": "docker", + "detail": "container_reload_failed", + "network": self.sandbox_network, + }, + cause=e, + ) from e + + attrs = getattr(self._container, "attrs", {}) or {} + networks = attrs.get("NetworkSettings", {}).get("Networks", {}) + endpoint = networks.get(self.sandbox_network) or {} + ip = endpoint.get("IPAddress") or endpoint.get("GlobalIPv6Address") + if not isinstance(ip, str) or not ip: + raise ExposedPortUnavailableError( + port=port, + exposed_ports=self.state.exposed_ports, + reason="backend_unavailable", + context={ + "backend": "docker", + "detail": "container_not_on_network", + "network": self.sandbox_network, + }, + ) + host = f"[{ip}]" if ":" in ip else ip + return ExposedPortEndpoint(host=host, port=port, tls=False) + + class StrixDockerSandboxClient(DockerSandboxClient): # Host directories to bind-mount into the container, set by the docker # backend before ``create()``. Each item is ``{source, target, read_only}``. @@ -117,6 +174,8 @@ class StrixDockerSandboxClient(DockerSandboxClient): extra_hosts = create_kwargs.setdefault("extra_hosts", {}) extra_hosts["host.docker.internal"] = "host-gateway" + _apply_sandbox_network(create_kwargs) + # Strix injection: host bind mounts (e.g. large repos passed via --mount) # that bypass the SDK's file-by-file LocalDir copy. bind_mounts = getattr(self, "strix_bind_mounts", ()) @@ -146,6 +205,15 @@ class StrixDockerSandboxClient(DockerSandboxClient): ) return container + async def create(self, **kwargs: Any) -> SandboxSession: + session = await super().create(**kwargs) + network = _sandbox_network() + inner = session._inner + if network and isinstance(inner, DockerSandboxSession): + inner.__class__ = StrixDockerSandboxSession + cast("StrixDockerSandboxSession", inner).sandbox_network = network + return session + async def delete(self, session: SandboxSession) -> SandboxSession: container_id = getattr(getattr(session._inner, "state", None), "container_id", None) if container_id: From 899e07d3a25b616f2d0908a35d05b2c61f3cd6b0 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:13:42 -0700 Subject: [PATCH 02/14] fix(core): bound per-agent image memory (proactive budget + inherited-context scrub) (#779) --- strix/config/settings.py | 2 + strix/core/agents.py | 5 +- strix/core/execution.py | 13 ++- strix/core/inputs.py | 7 +- strix/core/runner.py | 1 + strix/core/sessions.py | 157 ++++++++++++++++++++++++-------- tests/test_runner_rate_limit.py | 7 +- 7 files changed, 150 insertions(+), 42 deletions(-) diff --git a/strix/config/settings.py b/strix/config/settings.py index 9d16e083..84339218 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -56,6 +56,8 @@ class RuntimeSettings(BaseSettings): # on large repos). Above this, the user must bind-mount via ``--mount``. # Set to 0 (or less) to disable the pre-flight check entirely. max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB") + # Max screenshot/image tool outputs kept live per agent context (0 = none). + max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES") class TelemetrySettings(BaseSettings): diff --git a/strix/core/agents.py b/strix/core/agents.py index 24a7185f..5cb48d2f 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -10,6 +10,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast +from strix.core.sessions import session_write_lock + if TYPE_CHECKING: from agents.items import TResponseInputItem @@ -137,7 +139,8 @@ class AgentCoordinator: ) return False try: - await session.add_items([self._message_to_session_item(message)]) + async with session_write_lock(session): + await session.add_items([self._message_to_session_item(message)]) except Exception: logger.exception( "agent.send failed to append to SDK session target=%s", diff --git a/strix/core/execution.py b/strix/core/execution.py index 06dc3ddf..d26486de 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -17,7 +17,11 @@ from openai import APIError from strix.core.hooks import BudgetExceededError from strix.core.inputs import child_initial_input -from strix.core.sessions import open_agent_session, strip_all_images_from_session +from strix.core.sessions import ( + enforce_image_budget, + open_agent_session, + strip_all_images_from_session, +) if TYPE_CHECKING: @@ -349,6 +353,13 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 while True: try: await coordinator.mark_running(agent_id) + if session is not None: + max_images = context.get("max_context_images") + if isinstance(max_images, int): + try: + await enforce_image_budget(session, max_images) + except Exception: + logger.exception("image-budget enforcement failed for %s", agent_id) stream = Runner.run_streamed( agent, input=input_data, diff --git a/strix/core/inputs.py b/strix/core/inputs.py index e53ae321..1712fbd3 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -13,6 +13,7 @@ from strix.config.models import ( is_known_openai_bare_model, model_supports_reasoning, ) +from strix.core.sessions import scrub_images_from_items if TYPE_CHECKING: @@ -161,7 +162,11 @@ def child_initial_input( """ parts: list[str] = [] if parent_history: - rendered = json.dumps(parent_history, ensure_ascii=False, default=str) + rendered = json.dumps( + scrub_images_from_items(parent_history), + ensure_ascii=False, + default=str, + ) parts.append( "== Inherited context from parent (background only) ==\n" f"{rendered}\n" diff --git a/strix/core/runner.py b/strix/core/runner.py index 0ebcc48b..e6a15dc3 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -287,6 +287,7 @@ async def run_strix_scan( "parent_id": None, "interactive": interactive, "spawn_child_agent": spawn_child_agent, + "max_context_images": settings.runtime.max_context_images, } root_session = open_agent_session(root_id, agents_db) diff --git a/strix/core/sessions.py b/strix/core/sessions.py index 8e4f4142..67fbba95 100644 --- a/strix/core/sessions.py +++ b/strix/core/sessions.py @@ -2,64 +2,149 @@ from __future__ import annotations -import contextlib +import asyncio +import logging from typing import TYPE_CHECKING, Any, cast +from weakref import WeakKeyDictionary from agents.memory import SQLiteSession if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from agents.items import TResponseInputItem from agents.memory import Session +logger = logging.getLogger(__name__) + + def open_agent_session(agent_id: str, path: Path) -> SQLiteSession: path.parent.mkdir(parents=True, exist_ok=True) return SQLiteSession(session_id=agent_id, db_path=path) _IMAGE_REJECTED_TEXT = "[image rejected by the model]" +_IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]" +_INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]" + + +def _output_has_image(item_dict: dict[str, Any]) -> bool: + return ( + item_dict.get("type") == "function_call_output" + and isinstance(item_dict.get("output"), list) + and any(isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]) + ) + + +def _elided_output(item_dict: dict[str, Any], text: str) -> dict[str, Any]: + # Replace only image blocks; sibling text blocks are preserved. + output = item_dict.get("output") + blocks = output if isinstance(output, list) else [] + return { + "type": "function_call_output", + "call_id": item_dict.get("call_id"), + "output": [ + {"type": "input_text", "text": text} + if isinstance(block, dict) and block.get("type") == "input_image" + else block + for block in blocks + ], + } + + +_session_write_locks: WeakKeyDictionary[Session, asyncio.Lock] = WeakKeyDictionary() + + +def session_write_lock(session: Session) -> asyncio.Lock: + """Lock serialising all out-of-band writes to ``session``.""" + lock = _session_write_locks.get(session) + if lock is None: + lock = asyncio.Lock() + _session_write_locks[session] = lock + return lock + + +async def _rewrite_session( + session: Session, + transform: Callable[[list[Any]], tuple[list[Any], bool]], +) -> bool: + """Read-modify-write a session under its write lock, restoring on failure.""" + async with session_write_lock(session): + items = await session.get_items() + if not items: + return False + rebuilt, changed = transform(list(items)) + if not changed: + return False + rebuilt_items = cast("list[TResponseInputItem]", rebuilt) + original_items = cast("list[TResponseInputItem]", list(items)) + await session.clear_session() + try: + await session.add_items(rebuilt_items) + except Exception: + logger.exception("session rewrite failed; restoring original items") + await session.clear_session() + await session.add_items(original_items) + raise + return True async def strip_all_images_from_session(session: Session) -> bool: - items = await session.get_items() - if not items: + """Replace every image tool output with a text placeholder (rejection recovery).""" + + def _transform(items: list[Any]) -> tuple[list[Any], bool]: + rebuilt: list[Any] = [] + changed = False + for item in items: + item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None + if item_dict is not None and _output_has_image(item_dict): + rebuilt.append(_elided_output(item_dict, _IMAGE_REJECTED_TEXT)) + changed = True + else: + rebuilt.append(item) + return rebuilt, changed + + return await _rewrite_session(session, _transform) + + +async def enforce_image_budget(session: Session, max_images: int) -> bool: + """Keep only the most recent ``max_images`` image outputs; elide older ones.""" + if max_images < 0: return False - rebuilt: list[Any] = [] - changed = False - for item in items: - item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None - if ( - item_dict is not None - and item_dict.get("type") == "function_call_output" - and isinstance(item_dict.get("output"), list) - and any( - isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"] - ) - ): - rebuilt.append( - { - "type": "function_call_output", - "call_id": item_dict.get("call_id"), - "output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}], - }, - ) - changed = True - else: - rebuilt.append(item) + def _transform(items: list[Any]) -> tuple[list[Any], bool]: + image_indices = [ + i + for i, item in enumerate(items) + if isinstance(item, dict) and _output_has_image(cast("dict[str, Any]", item)) + ] + if len(image_indices) <= max_images: + return items, False + to_elide = set(image_indices[: len(image_indices) - max_images]) + rebuilt = [ + _elided_output(cast("dict[str, Any]", item), _IMAGE_ELIDED_TEXT) + if i in to_elide + else item + for i, item in enumerate(items) + ] + return rebuilt, True - if not changed: - return False + return await _rewrite_session(session, _transform) - rebuilt_items = cast("list[TResponseInputItem]", rebuilt) - await session.clear_session() - try: - await session.add_items(rebuilt_items) - except Exception: - with contextlib.suppress(Exception): - await session.add_items(rebuilt_items) - raise - return True + +def scrub_images_from_items(items: list[Any]) -> list[Any]: + """Return a copy of ``items`` with every image block replaced by text.""" + + def _scrub(obj: Any) -> Any: + if isinstance(obj, dict): + if obj.get("type") == "input_image": + return {"type": "input_text", "text": _INHERITED_IMAGE_TEXT} + return {k: _scrub(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_scrub(v) for v in obj] + return obj + + return [_scrub(item) for item in items] diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 7e708eb5..401caa15 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -37,7 +37,8 @@ async def test_persistent_rate_limit_stops_gracefully( model="openai/gpt-4o", reasoning_effort="high", force_required_tool_choice=False, - ) + ), + runtime=types.SimpleNamespace(max_context_images=3), ) monkeypatch.setattr(runner, "load_settings", lambda: settings) monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None) @@ -54,8 +55,8 @@ async def test_persistent_rate_limit_stops_gracefully( async def _cleanup(*_args: Any, **_kwargs: Any) -> None: return None - monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse) - monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup) + monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse) # type: ignore[attr-defined] + monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup) # type: ignore[attr-defined] monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task") monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "") From 84185db23b77219936c4f5fc5ec4ad48b20e882b Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 01:15:19 +0000 Subject: [PATCH 03/14] feat(runtime): opt-in resource limits for docker sandbox containers Apply cgroup caps (mem_limit, shm_size, nano_cpus, pids_limit) to the sandbox container from STRIX_SANDBOX_* env vars. Unset values keep docker's unbounded default, so behavior is unchanged unless opted in. --- strix/runtime/docker_client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 03010a12..3f364103 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -65,6 +65,30 @@ def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None: create_kwargs.pop("ports", None) +def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None: + """Apply optional cgroup resource caps from the environment. Unset/blank + values leave docker's default (unbounded), so this is opt-in per host.""" + mem_limit = os.environ.get("STRIX_SANDBOX_MEM_LIMIT", "").strip() + if mem_limit: + create_kwargs["mem_limit"] = mem_limit + + shm_size = os.environ.get("STRIX_SANDBOX_SHM_SIZE", "").strip() + if shm_size: + create_kwargs["shm_size"] = shm_size + + cpus = os.environ.get("STRIX_SANDBOX_CPUS", "").strip() + if cpus: + with contextlib.suppress(ValueError): + nano_cpus = int(float(cpus) * 1_000_000_000) + if nano_cpus > 0: + create_kwargs["nano_cpus"] = nano_cpus + + pids_limit = os.environ.get("STRIX_SANDBOX_PIDS_LIMIT", "").strip() + if pids_limit: + with contextlib.suppress(ValueError): + create_kwargs["pids_limit"] = int(pids_limit) + + class StrixDockerSandboxSession(DockerSandboxSession): sandbox_network: str = "" @@ -175,6 +199,7 @@ class StrixDockerSandboxClient(DockerSandboxClient): extra_hosts["host.docker.internal"] = "host-gateway" _apply_sandbox_network(create_kwargs) + _apply_resource_limits(create_kwargs) # Strix injection: host bind mounts (e.g. large repos passed via --mount) # that bypass the SDK's file-by-file LocalDir copy. From 575e10a4048d720e6d8ea74bcb4908b3ddffebcd Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 01:19:43 +0000 Subject: [PATCH 04/14] fix(runtime): also suppress OverflowError for non-finite STRIX_SANDBOX_CPUS --- strix/runtime/docker_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 3f364103..b393e6d2 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -78,7 +78,7 @@ def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None: cpus = os.environ.get("STRIX_SANDBOX_CPUS", "").strip() if cpus: - with contextlib.suppress(ValueError): + with contextlib.suppress(ValueError, OverflowError): nano_cpus = int(float(cpus) * 1_000_000_000) if nano_cpus > 0: create_kwargs["nano_cpus"] = nano_cpus From 16982646dfdca5e51d4f78b584cf099121c52220 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 01:25:31 +0000 Subject: [PATCH 05/14] fix(runtime): bound nano_cpus to docker's int64 NanoCPUs range --- strix/runtime/docker_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index b393e6d2..4dfacf6d 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -80,7 +80,7 @@ def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None: if cpus: with contextlib.suppress(ValueError, OverflowError): nano_cpus = int(float(cpus) * 1_000_000_000) - if nano_cpus > 0: + if 0 < nano_cpus <= 2**63 - 1: create_kwargs["nano_cpus"] = nano_cpus pids_limit = os.environ.get("STRIX_SANDBOX_PIDS_LIMIT", "").strip() From 38c2936f6950603e298f35a71e7639897cb2851f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:50:52 +0000 Subject: [PATCH 06/14] Revert "fix(runtime): retry transient sandbox startup failures (#768)" This reverts commit 40f4e67320bf229bee064a33fe4dbd300c6b8e1b. --- strix/runtime/backends.py | 134 +-------------------- tests/test_backend_retries.py | 218 ---------------------------------- 2 files changed, 3 insertions(+), 349 deletions(-) delete mode 100644 tests/test_backend_retries.py diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index b73fdbad..d7eba335 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -2,9 +2,7 @@ from __future__ import annotations -import asyncio import logging -import os from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -18,128 +16,6 @@ logger = logging.getLogger(__name__) SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]] -_DEFAULT_START_ATTEMPTS = 3 -_START_BACKOFF_SECONDS = 2.0 -_TRANSIENT_TIMEOUT_NAMES = { - "ConnectTimeout", - "PoolTimeout", - "ReadTimeout", - "TimeoutError", - "TimeoutException", - "WriteTimeout", -} -_TRANSIENT_CONNECTION_NAMES = { - "ConnectError", - "ConnectionError", - "ConnectionResetError", - "ReadError", - "WriteError", -} - - -def _start_attempts() -> int: - raw = os.environ.get("STRIX_SANDBOX_START_ATTEMPTS") - if raw is None: - return _DEFAULT_START_ATTEMPTS - try: - attempts = int(raw) - except ValueError: - logger.warning( - "Invalid STRIX_SANDBOX_START_ATTEMPTS=%r; using %d", - raw, - _DEFAULT_START_ATTEMPTS, - ) - return _DEFAULT_START_ATTEMPTS - if attempts < 1: - logger.warning( - "STRIX_SANDBOX_START_ATTEMPTS must be positive; using %d", - _DEFAULT_START_ATTEMPTS, - ) - return _DEFAULT_START_ATTEMPTS - return attempts - - -def _exception_chain(error: BaseException) -> list[BaseException]: - chain: list[BaseException] = [] - pending: list[BaseException | None] = [error] - seen: set[int] = set() - while pending: - current = pending.pop() - if current is None or id(current) in seen: - continue - seen.add(id(current)) - chain.append(current) - pending.extend( - ( - current.__cause__, - current.__context__, - getattr(current, "cause", None), - ) - ) - return chain - - -def _is_transient_start_error(error: BaseException) -> bool: - for cause in _exception_chain(error): - name = type(cause).__name__ - module = type(cause).__module__ - if isinstance(cause, TimeoutError | ConnectionError | ConnectionResetError): - return True - if name in _TRANSIENT_TIMEOUT_NAMES: - return True - if name in _TRANSIENT_CONNECTION_NAMES and ( - module.startswith(("httpcore", "httpx", "agents")) - or name in {"ConnectionError", "ConnectionResetError"} - ): - return True - return False - - -async def start_session_with_retry( - client: Any, - create_session: Callable[[], Awaitable[Any]], - *, - attempts: int | None = None, -) -> Any: - """Start a sandbox session, retrying transient transport failures. - - Backend implementations should use this helper when they own both session - creation and ``session.start()`` so failed starts can be torn down before a - retry. The caller owns the manifest and any temporary source directories - until this helper returns. - """ - max_attempts = attempts if attempts is not None else _start_attempts() - for attempt in range(1, max_attempts + 1): - session: Any | None = None - try: - session = await create_session() - assert session is not None - await session.start() - except Exception as exc: - if session is not None: - try: - await client.delete(session) - except Exception as teardown_error: - logger.warning( - "Failed to tear down sandbox after start failure; aborting retry", - exc_info=True, - ) - raise exc from teardown_error - transient = _is_transient_start_error(exc) - if not transient or attempt == max_attempts: - raise - delay = _START_BACKOFF_SECONDS * (2 ** (attempt - 1)) - logger.warning( - "Transient sandbox start failure; retrying attempt %d/%d in %.1fs", - attempt + 1, - max_attempts, - delay, - ) - await asyncio.sleep(delay) - else: - return session - raise AssertionError("sandbox start retry loop completed without returning or raising") - async def _docker_backend( *, @@ -174,10 +50,8 @@ async def _docker_backend( client = StrixDockerSandboxClient(docker.from_env()) client.strix_bind_mounts = bind_mounts or [] options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports) - session = await start_session_with_retry( - client, - lambda: client.create(options=options, manifest=manifest), - ) + session = await client.create(options=options, manifest=manifest) + await session.start() return client, session @@ -209,9 +83,7 @@ def register_backend(name: str, backend: SandboxBackend) -> None: Intended for downstream users who ship their own runtime — register before any ``session_manager.create_or_reuse`` call. Re-registering - an existing name overwrites the prior entry. Backends that own both - session creation and ``session.start()`` should use - :func:`start_session_with_retry`. + an existing name overwrites the prior entry. """ _BACKENDS[name] = backend logger.info("Registered sandbox backend: %s", name) diff --git a/tests/test_backend_retries.py b/tests/test_backend_retries.py deleted file mode 100644 index b18d4a0b..00000000 --- a/tests/test_backend_retries.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Tests for transient sandbox start retries.""" - -from __future__ import annotations - -import shutil -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import pytest -from agents.sandbox.errors import ( - LocalDirReadError, - WorkspaceArchiveWriteError, - WorkspaceStartError, -) - -from strix.runtime import session_manager -from strix.runtime.backends import start_session_with_retry - - -class _FakeSession: - def __init__(self, failures: list[BaseException]) -> None: - self._failures = iter(failures) - - async def start(self) -> None: - try: - raise next(self._failures) - except StopIteration: - return - - async def resolve_exposed_port(self, _port: int) -> SimpleNamespace: - return SimpleNamespace(tls=False, host="127.0.0.1", port=48080) - - -class _FakeClient: - def __init__(self, *, delete_error: BaseException | None = None) -> None: - self.created = 0 - self.deleted: list[_FakeSession] = [] - self.delete_error = delete_error - - async def create(self) -> _FakeSession: - self.created += 1 - failures: list[BaseException] = [] - if self.created == 1: - failures = [ - WorkspaceStartError( - path=Path("/workspace"), - cause=WorkspaceArchiveWriteError( - path=Path("/workspace"), - cause=TimeoutError("transient transport timeout"), - ), - ) - ] - return _FakeSession(failures) - - async def delete(self, session: _FakeSession) -> None: - self.deleted.append(session) - if self.delete_error is not None: - raise self.delete_error - - -async def test_transient_workspace_failure_retries_and_tears_down( - monkeypatch: pytest.MonkeyPatch, -) -> None: - client = _FakeClient() - sleeps: list[float] = [] - - async def record_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("strix.runtime.backends.asyncio.sleep", record_sleep) - - session = await start_session_with_retry(client, client.create, attempts=3) - - assert isinstance(session, _FakeSession) - assert client.created == 2 - assert len(client.deleted) == 1 - assert sleeps == [2.0] - - -async def test_non_transient_workspace_failure_does_not_retry() -> None: - client = _FakeClient() - session = _FakeSession([LocalDirReadError(src=Path("/workspace/repo"))]) - - async def create_session() -> _FakeSession: - client.created += 1 - return session - - with pytest.raises(LocalDirReadError): - await start_session_with_retry(client, create_session, attempts=3) - - assert client.created == 1 - assert client.deleted == [session] - - -async def test_teardown_failure_raises_original_error_without_retry() -> None: - start_error = WorkspaceStartError( - path=Path("/workspace"), - cause=TimeoutError("transient transport timeout"), - ) - teardown_error = RuntimeError("teardown failed") - client = _FakeClient(delete_error=teardown_error) - session = _FakeSession([start_error]) - - async def create_session() -> _FakeSession: - client.created += 1 - return session - - with pytest.raises(WorkspaceStartError) as caught: - await start_session_with_retry(client, create_session, attempts=3) - - assert caught.value is start_error - assert caught.value.__cause__ is teardown_error - assert client.created == 1 - assert client.deleted == [session] - - -async def test_each_transient_attempt_is_torn_down(monkeypatch: pytest.MonkeyPatch) -> None: - client = _FakeClient() - client.created = 0 - sessions: list[_FakeSession] = [] - sleeps: list[float] = [] - - async def record_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("strix.runtime.backends.asyncio.sleep", record_sleep) - - async def create_session() -> _FakeSession: - client.created += 1 - failures: list[BaseException] = [] - if client.created < 3: - failures = [ - WorkspaceStartError( - path=Path("/workspace"), - cause=TimeoutError("transient transport timeout"), - ) - ] - session = _FakeSession(failures) - sessions.append(session) - return session - - result = await start_session_with_retry(client, create_session, attempts=3) - - assert result is sessions[2] - assert client.deleted == sessions[:2] - assert sleeps == [2.0, 4.0] - - -async def test_staged_dirs_survive_retries_and_cleanup_once( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "real.txt").write_text("content") - (repo / "link.txt").symlink_to(repo / "real.txt") - - client = _FakeClient() - observed_paths: list[Path] = [] - sleeps: list[float] = [] - original_rmtree = shutil.rmtree # pyright: ignore[reportDeprecated] - removed_paths: list[Path] = [] - - async def record_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("strix.runtime.backends.asyncio.sleep", record_sleep) - - def record_rmtree(path: str | Path, **kwargs: Any) -> None: - removed_paths.append(Path(path)) - original_rmtree(path, **kwargs) # pyright: ignore[reportDeprecated] - - monkeypatch.setattr("strix.runtime.session_manager.shutil.rmtree", record_rmtree) - monkeypatch.setattr( - session_manager, - "load_settings", - lambda: SimpleNamespace(runtime=SimpleNamespace(backend="fake")), - ) - monkeypatch.setattr(session_manager, "bootstrap_caido", _bootstrap_caido) - - async def fake_backend(**kwargs: Any) -> tuple[_FakeClient, _FakeSession]: - staged_path = kwargs["manifest"].entries["repo"].src - - async def create_session() -> _FakeSession: - observed_paths.append(Path(staged_path)) - return await client.create() - - session = await start_session_with_retry(client, create_session, attempts=3) - return client, session - - def fake_get_backend(_name: str) -> Any: - return fake_backend - - monkeypatch.setattr(session_manager, "get_backend", fake_get_backend) - - try: - await session_manager.create_or_reuse( - "retry-test", - image="test-image", - local_sources=[ - { - "source_path": str(repo), - "workspace_subdir": "repo", - } - ], - ) - finally: - await session_manager.cleanup("retry-test") - - assert len(observed_paths) == 2 - assert observed_paths[0] == observed_paths[1] - assert observed_paths[0] in removed_paths - assert not observed_paths[0].exists() - - -async def _bootstrap_caido(*_args: Any, **_kwargs: Any) -> object: - return object() From 4b619d57a0c61e7a9cee8473bb0d831c8154dc0e Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 11:31:36 +0000 Subject: [PATCH 07/14] docs(prompts,skills): bound recon output for shared-disk hygiene Add lightweight, always-on disk-hygiene guidance so agents keep recon artifacts bounded on the shared /workspace instead of writing very large uncapped crawl output. - system_prompt.jinja: DISK & SCRATCH HYGIENE note in the shared-workspace block; recon PHASE 1 crawl bullet asks to bound each crawl and tidy up. - skills/tooling/katana.md: bound the baseline/deep examples with -ct, add a Keeping-output-manageable note (bound by -ct/-d, reserve -jsl/-kf all for narrowed targets, check du -sh, dedupe and remove raw .jsonl). --- strix/agents/prompts/system_prompt.jinja | 8 +++++++- strix/skills/tooling/katana.md | 12 +++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index bfd0ee76..6141179b 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -240,12 +240,18 @@ AGENT ISOLATION & SANDBOXING: - All agents share the same /workspace directory and proxy history - Agents can see each other's files and proxy traffic for better collaboration +DISK & SCRATCH HYGIENE: +- /workspace is a shared, finite disk used by all agents at once — be a considerate tenant +- Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything" +- Send large tool output to /workspace/scratch/, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output +- If disk gets tight or a write fails for space, check what's large under /workspace and clean up before continuing + MANDATORY INITIAL PHASES: BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING): - COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection - MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs -- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files +- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, write to /workspace/scratch, and tidy up raw output once endpoints are extracted - ENUMERATE technologies: frameworks, libraries, versions, dependencies - Reconnaissance should normally happen before targeted vulnerability discovery unless the correct next move is already obvious or the user/system explicitly asks to prioritize a specific area first - ONLY AFTER comprehensive mapping → proceed to vulnerability testing diff --git a/strix/skills/tooling/katana.md b/strix/skills/tooling/katana.md index 82ed6eb9..1abb5da2 100644 --- a/strix/skills/tooling/katana.md +++ b/strix/skills/tooling/katana.md @@ -37,13 +37,13 @@ High-signal flags: - `-silent`, `-j, -jsonl`, `-o ` output controls Agent-safe baseline for automation: -`mkdir -p crawl && katana -u https://target.tld -d 3 -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl` +`mkdir -p crawl && katana -u https://target.tld -d 3 -ct 10m -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl` Common patterns: - Fast crawl baseline: `katana -u https://target.tld -d 3 -jc -silent` -- Deeper JS-aware crawl: - `katana -u https://target.tld -d 5 -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt` +- Deeper JS-aware crawl (narrowed target; keep it time-bounded): + `katana -u https://target.tld -d 5 -ct 15m -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt` - Multi-target run with JSONL output: `katana -list urls.txt -d 3 -jc -silent -j -o katana.jsonl` - Headless crawl with local Chrome: @@ -59,6 +59,12 @@ Critical correctness rules: - For `-kf`, keep depth at least `-d 3` so known files are fully covered. - If writing to a file, ensure parent directory exists before `-o`. +Keeping output manageable: +- katana does not cap its own output size, so bound crawls with `-ct` (crawl-duration) and `-d` (depth); on large sites an unbounded `-jsl` / `-kf all` deep crawl can grow very large. +- Reserve deep JS crawling (`-jsl`, `-kf all`, higher `-d`) for a specific narrowed target rather than broad scopes. +- After a crawl, glance at output size (`du -sh `); if it looks outsized for the scope, tighten `-d`/`-ct`/`-ef` or split per host. +- Dedupe and reduce before use (`sort -u`), and remove the raw `.jsonl` once you've parsed the URLs you need. + Usage rules: - Keep `-d`, `-c`, `-p`, and `-rl` explicit for reproducible runs. - Use `-ef` early to reduce static-file noise before fuzzing. From 98990bae45949a07974ba42405fed305fb654920 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 11:38:28 +0000 Subject: [PATCH 08/14] docs(prompts,skills): scope cleanup to own files; dedupe JSONL by URL Address Greptile review: - system_prompt: only clean up your own task's files; don't delete another agent's files in the shared workspace unless confirmed unused. - katana.md: extract+dedupe URLs with jq before removing raw .jsonl (sort -u on JSONL compares whole records, not URLs). --- strix/agents/prompts/system_prompt.jinja | 2 +- strix/skills/tooling/katana.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 6141179b..2a7b065d 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -244,7 +244,7 @@ DISK & SCRATCH HYGIENE: - /workspace is a shared, finite disk used by all agents at once — be a considerate tenant - Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything" - Send large tool output to /workspace/scratch/, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output -- If disk gets tight or a write fails for space, check what's large under /workspace and clean up before continuing +- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use MANDATORY INITIAL PHASES: diff --git a/strix/skills/tooling/katana.md b/strix/skills/tooling/katana.md index 1abb5da2..66b4928d 100644 --- a/strix/skills/tooling/katana.md +++ b/strix/skills/tooling/katana.md @@ -63,7 +63,7 @@ Keeping output manageable: - katana does not cap its own output size, so bound crawls with `-ct` (crawl-duration) and `-d` (depth); on large sites an unbounded `-jsl` / `-kf all` deep crawl can grow very large. - Reserve deep JS crawling (`-jsl`, `-kf all`, higher `-d`) for a specific narrowed target rather than broad scopes. - After a crawl, glance at output size (`du -sh `); if it looks outsized for the scope, tighten `-d`/`-ct`/`-ef` or split per host. -- Dedupe and reduce before use (`sort -u`), and remove the raw `.jsonl` once you've parsed the URLs you need. +- For JSONL output, extract and dedupe URLs before use (`jq -r '.url' | sort -u > urls.txt`), then remove the raw `.jsonl` once you've preserved any metadata you still need. Usage rules: - Keep `-d`, `-c`, `-p`, and `-rl` explicit for reproducible runs. From 89ee7b9e5ed499df1b33584be9f4cff35194a1ea Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 11:42:29 +0000 Subject: [PATCH 09/14] docs(skills): add research-backed katana output-reduction flags Per projectdiscovery katana docs, add the flags that actually bound crawl output size and a reduce-then-delete workflow: - -mdp (max-domain-pages; default is unlimited), -fsu (filter-similar), -fs scope, -f url (URL-only), -or/-ob (omit raw/body), -mrs. - Baseline now includes -mdp 2000 -fsu; new 'Keeping output small' section: bound scope/volume, shrink records, distil then delete raw crawls. --- strix/skills/tooling/katana.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/strix/skills/tooling/katana.md b/strix/skills/tooling/katana.md index 66b4928d..7bece109 100644 --- a/strix/skills/tooling/katana.md +++ b/strix/skills/tooling/katana.md @@ -24,7 +24,15 @@ High-signal flags: - `-p, -parallelism ` concurrent input targets - `-rl, -rate-limit ` request rate limit - `-timeout ` request timeout +- `-ct, -crawl-duration ` maximum time to crawl the target - `-retry ` retry count +- `-mdp, -max-domain-pages ` cap pages crawled per domain (default: unlimited) +- `-fsu, -filter-similar` collapse similar URLs (e.g. /users/123 and /users/456) +- `-fs, -field-scope ` crawl scope (default `rdn` = root domain + ALL subdomains) +- `-f, -field ` emit only one field (e.g. `-f url` for a plain URL list) +- `-or, -omit-raw` omit raw request/response from JSONL output +- `-ob, -omit-body` omit response body from JSONL output +- `-mrs, -max-response-size ` cap per-response bytes read (default 4194304) - `-ef, -extension-filter ` extension exclusions - `-tlsi, -tls-impersonate` experimental JA3/TLS impersonation - `-hl, -headless` enable hybrid headless crawling @@ -37,7 +45,7 @@ High-signal flags: - `-silent`, `-j, -jsonl`, `-o ` output controls Agent-safe baseline for automation: -`mkdir -p crawl && katana -u https://target.tld -d 3 -ct 10m -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl` +`mkdir -p crawl && katana -u https://target.tld -d 3 -ct 10m -mdp 2000 -fsu -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl` Common patterns: - Fast crawl baseline: @@ -59,11 +67,12 @@ Critical correctness rules: - For `-kf`, keep depth at least `-d 3` so known files are fully covered. - If writing to a file, ensure parent directory exists before `-o`. -Keeping output manageable: -- katana does not cap its own output size, so bound crawls with `-ct` (crawl-duration) and `-d` (depth); on large sites an unbounded `-jsl` / `-kf all` deep crawl can grow very large. -- Reserve deep JS crawling (`-jsl`, `-kf all`, higher `-d`) for a specific narrowed target rather than broad scopes. -- After a crawl, glance at output size (`du -sh `); if it looks outsized for the scope, tighten `-d`/`-ct`/`-ef` or split per host. -- For JSONL output, extract and dedupe URLs before use (`jq -r '.url' | sort -u > urls.txt`), then remove the raw `.jsonl` once you've preserved any metadata you still need. +Keeping output small (katana has NO default page cap, so plan for volume): +- Bound scope and volume: `-fs fqdn` (or `-cs`/`-cos` regex) so the crawl doesn't wander across every subdomain, `-mdp ` to cap pages per domain, `-fsu` to collapse near-identical URLs, and `-ct`/`-d` to bound time and depth. +- Shrink each record: default JSONL is verbose. If you only need endpoints, emit a plain URL list with `-f url` instead of `-j`. If you need JSONL, drop the heavy parts with `-or` (omit raw) and `-ob` (omit body), and lower `-mrs` to cap per-response bytes. +- Reserve `-jsl` / `-kf all` / higher `-d` for a specific narrowed target — they multiply output fast on large sites. +- Reduce, then delete: once the crawl finishes, extract just what you need (e.g. `katana ... -f url -o urls.txt` or `sort -u` a URL list, or a short note of interesting paths) and remove the raw crawl file/dir. Don't keep large raw crawls around after you've distilled them. +- Sanity-check size (`du -sh `); if it's outsized for the scope, tighten `-fs`/`-mdp`/`-fsu`/`-d`/`-ct` and re-run rather than keeping it. Usage rules: - Keep `-d`, `-c`, `-p`, and `-rl` explicit for reproducible runs. From 6786d24acad74b70f833d3c80a6cf818a9c66a00 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:16:12 -0700 Subject: [PATCH 10/14] docs(tools): guide proportional wait_for_message timeouts (#784) --- strix/tools/agents_graph/tools.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index a72b311c..478c1efb 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -229,7 +229,8 @@ async def wait_for_message( # noqa: PLR0911 Use when you have nothing useful to do until a child/peer responds — typically after spawning subagents and you want to wait for their completion reports. The agent automatically resumes when any - message arrives. + message arrives, so pick a ``timeout_seconds`` proportional to the + work you're awaiting. **Critical caveats:** @@ -246,9 +247,19 @@ async def wait_for_message( # noqa: PLR0911 reason: One-line note shown in graph snapshots while you're waiting (helps a human or sibling agent debug who's stuck on what). - timeout_seconds: Hard cap (default 600s). On timeout the tool - returns and you decide whether to keep working or wait - again. + timeout_seconds: Max seconds to wait (default 600). This is only + a cap — the tool returns the INSTANT a message arrives, so a + larger value never makes you wait longer when the reply does + come. Right-size it to what you're waiting on: a short wait + (e.g. 10-60s) for a quick ack or a small/fast subtask, and a + longer one (e.g. ~100-200s) only for genuinely long-running + work (deep recon, exploitation, a full sub-scan). The cap only + bites when the expected message never arrives — so an oversized + timeout on a trivial wait just strands you idle until it + elapses. On timeout the tool returns and you decide whether to + keep working or wait again. (Applies to autonomous multi-agent + runs; in interactive/chat sessions the agent instead parks until + a message arrives and this cap is not enforced.) """ inner = _ctx(ctx) coordinator = coordinator_from_context(inner) From f6bd6179642f04f77071985287fc79cb80ef6ac4 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 15:59:25 +0000 Subject: [PATCH 11/14] docs(prompts,skills): stop hardcoding /workspace/scratch path The sandbox never creates /workspace/scratch, so guidance pointing agents there failed on first write. Make the Python/exec_command and recon output-hygiene guidance path-agnostic (write to a file, relative to the working dir) instead of naming a directory that may not exist. --- strix/agents/prompts/system_prompt.jinja | 10 +++++----- strix/skills/tooling/python.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 2a7b065d..b3d8e047 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -168,9 +168,9 @@ EFFICIENCY TACTICS: - Download additional tools as needed for specific tasks - Run multiple scans in parallel when possible - Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage -- Use `exec_command` for Python code: write reusable scripts under - `/workspace/scratch/` and run them with `python3`. For one-off snippets, - `python3 -c` or a here-document is acceptable. +- Use `exec_command` for Python code: write reusable scripts to a file and + run them with `python3`. For one-off snippets, `python3 -c` or a + here-document is acceptable. - For Caido proxy automation inside Python, explicitly import from `caido_api`: `from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules` @@ -243,7 +243,7 @@ AGENT ISOLATION & SANDBOXING: DISK & SCRATCH HYGIENE: - /workspace is a shared, finite disk used by all agents at once — be a considerate tenant - Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything" -- Send large tool output to /workspace/scratch/, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output +- Redirect large tool output to a file, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output - If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use MANDATORY INITIAL PHASES: @@ -251,7 +251,7 @@ MANDATORY INITIAL PHASES: BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING): - COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection - MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs -- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, write to /workspace/scratch, and tidy up raw output once endpoints are extracted +- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, and tidy up raw output once endpoints are extracted - ENUMERATE technologies: frameworks, libraries, versions, dependencies - Reconnaissance should normally happen before targeted vulnerability discovery unless the correct next move is already obvious or the user/system explicitly asks to prioritize a specific area first - ONLY AFTER comprehensive mapping → proceed to vulnerability testing diff --git a/strix/skills/tooling/python.md b/strix/skills/tooling/python.md index 65ab1cff..bc53b043 100644 --- a/strix/skills/tooling/python.md +++ b/strix/skills/tooling/python.md @@ -7,9 +7,9 @@ description: Run Python through exec_command in the SDK sandbox. Use the image-b Use `exec_command` for Python. There is no separate Strix Python executor. -Prefer writing reusable scripts to `/workspace/scratch/.py` and -running them with `python3 /workspace/scratch/.py`. For short -one-off transformations, `python3 -c` or a small here-document is fine. +Prefer writing reusable scripts to a `.py` file and running them with +`python3 .py`. For short one-off transformations, `python3 -c` or a +small here-document is fine. The `shell` parameter on `exec_command` is for swapping POSIX shells (`bash`/`zsh`/`sh`), not for picking interpreters. Put the interpreter @@ -84,8 +84,8 @@ automatically, so it shows up in `list_requests` and you can use For iterative exploit work, put code in a file: ```text -1. Create or edit `/workspace/scratch/exploit.py` with `apply_patch`. -2. Run it with `exec_command`: `python3 /workspace/scratch/exploit.py`. +1. Create or edit `exploit.py` with `apply_patch`. +2. Run it with `exec_command`: `python3 exploit.py`. 3. Edit and rerun until the proof-of-concept is reliable. ``` From a529d7f73a7c719289fd66c455f7bf17376e3823 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 16:04:25 +0000 Subject: [PATCH 12/14] docs(python skill): use a distinctive PoC filename to avoid clobbering project files --- strix/skills/tooling/python.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/strix/skills/tooling/python.md b/strix/skills/tooling/python.md index bc53b043..6bef2e34 100644 --- a/strix/skills/tooling/python.md +++ b/strix/skills/tooling/python.md @@ -84,8 +84,9 @@ automatically, so it shows up in `list_requests` and you can use For iterative exploit work, put code in a file: ```text -1. Create or edit `exploit.py` with `apply_patch`. -2. Run it with `exec_command`: `python3 exploit.py`. +1. Create or edit a distinctively-named script (e.g. `strix_poc.py`, to avoid + clobbering project files) with `apply_patch`. +2. Run it with `exec_command`: `python3 strix_poc.py`. 3. Edit and rerun until the proof-of-concept is reliable. ``` From 3a50a5ab0ea2f8c62818474809fdf19037e50041 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Thu, 16 Jul 2026 16:10:28 +0000 Subject: [PATCH 13/14] docs(python skill): recommend a task-unique PoC filename to avoid inter-agent collisions --- strix/skills/tooling/python.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/strix/skills/tooling/python.md b/strix/skills/tooling/python.md index 6bef2e34..d80aa69f 100644 --- a/strix/skills/tooling/python.md +++ b/strix/skills/tooling/python.md @@ -84,9 +84,9 @@ automatically, so it shows up in `list_requests` and you can use For iterative exploit work, put code in a file: ```text -1. Create or edit a distinctively-named script (e.g. `strix_poc.py`, to avoid - clobbering project files) with `apply_patch`. -2. Run it with `exec_command`: `python3 strix_poc.py`. +1. Create or edit a task-unique script (e.g. `poc_.py`, so it can't + clobber a project file or another agent's script) with `apply_patch`. +2. Run it with `exec_command`: `python3 poc_.py`. 3. Edit and rerun until the proof-of-concept is reliable. ``` From e2eb39a02e4e945cd63b4a787c6454590f9f66ef Mon Sep 17 00:00:00 2001 From: Ahmed Allam <49919286+0xallam@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:17:46 -0700 Subject: [PATCH 14/14] fix(runtime): cap sandbox container logs to prevent host disk exhaustion (#785) --- strix/runtime/docker_client.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 4dfacf6d..11cdff57 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -42,6 +42,7 @@ from agents.sandbox.session.sandbox_session import SandboxSession from agents.sandbox.types import ExposedPortEndpoint from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore] from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore] +from docker.types import LogConfig # type: ignore[import-untyped, unused-ignore] from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore] from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore] from requests.exceptions import RequestException @@ -89,6 +90,26 @@ def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None: create_kwargs["pids_limit"] = int(pids_limit) +def _apply_log_limits(create_kwargs: dict[str, Any]) -> None: + """Bound the container's json-file log so a runaway process in the sandbox + (e.g. a tool that busy-loops writing to stdout) cannot fill the host disk + and take the Docker daemon down with it. + + Unlike the cgroup caps above, this defaults **on** — docker's own default + is an unbounded json-file, which is unsafe for an autonomous agent that + executes arbitrary commands. ``max-file`` rotation means the on-disk cap is + ``max-size * max-file``. Set ``STRIX_SANDBOX_LOG_MAX_SIZE`` to ``0``/``off`` + to opt back out to docker's default.""" + max_size = os.environ.get("STRIX_SANDBOX_LOG_MAX_SIZE", "50m").strip() + if max_size.lower() in ("0", "off", "none", "unlimited"): + return + max_file = os.environ.get("STRIX_SANDBOX_LOG_MAX_FILE", "3").strip() or "3" + create_kwargs["log_config"] = LogConfig( + type=LogConfig.types.JSON, + config={"max-size": max_size, "max-file": max_file}, + ) + + class StrixDockerSandboxSession(DockerSandboxSession): sandbox_network: str = "" @@ -200,6 +221,7 @@ class StrixDockerSandboxClient(DockerSandboxClient): _apply_sandbox_network(create_kwargs) _apply_resource_limits(create_kwargs) + _apply_log_limits(create_kwargs) # Strix injection: host bind mounts (e.g. large repos passed via --mount) # that bypass the SDK's file-by-file LocalDir copy.