[verified] fix: close 0.3.0 release security gaps
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled

This commit is contained in:
m4
2026-09-03 10:42:46 +08:00
parent c683f6e739
commit 5d893c1dc6
15 changed files with 853 additions and 60 deletions
+23 -9
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import json
import math
from collections.abc import Awaitable, Callable
from typing import Any
@@ -19,23 +20,36 @@ class SubagentTimeoutMiddleware(AgentMiddleware):
def name(self) -> str:
return "subagent_timeout"
def __init__(self, timeout_seconds: float = 180.0) -> None:
def __init__(
self,
timeout_seconds: float = 180.0,
cancellation_grace_seconds: float = 0.25,
) -> None:
super().__init__()
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
raise ValueError("timeout_seconds must be finite and positive")
if (
not math.isfinite(cancellation_grace_seconds)
or cancellation_grace_seconds <= 0
):
raise ValueError("cancellation_grace_seconds must be finite and positive")
self.timeout_seconds = float(timeout_seconds)
self.cancellation_grace_seconds = float(cancellation_grace_seconds)
@staticmethod
async def _cancel_task(task: asyncio.Future[Any]) -> None:
async def _cancel_task(self, task: asyncio.Future[Any]) -> None:
task.cancel()
try:
await asyncio.shield(task)
done, _pending = await asyncio.wait(
{task}, timeout=self.cancellation_grace_seconds
)
except asyncio.CancelledError:
if not task.done():
task.add_done_callback(SubagentTimeoutMiddleware._consume_task_result)
raise
except Exception:
pass
raise
if task in done:
self._consume_task_result(task)
else:
task.add_done_callback(SubagentTimeoutMiddleware._consume_task_result)
@staticmethod
def _consume_task_result(task: asyncio.Future[Any]) -> None:
+113 -6
View File
@@ -22,6 +22,7 @@ from pathlib import Path
from typing import Any
from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
from filelock import FileLock
from .workspace_files import ScopedFilesystemBackend
@@ -54,19 +55,125 @@ class NativeSandboxInstallation:
socat: Path | None = None
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _runtime_assets_root() -> Path:
return Path(__file__).with_name("native_sandbox_runtime")
def _runtime_root() -> Path:
configured = os.getenv("EVOSCIENTIST_NATIVE_SANDBOX_ROOT", "").strip()
if configured:
return Path(configured).expanduser().resolve()
from .paths import DATA_DIR
return (DATA_DIR / "runtime" / "native-sandbox").expanduser().resolve()
def _runtime_install_complete(root: Path) -> bool:
package_root = root / "node_modules" / "@anthropic-ai" / "sandbox-runtime"
try:
metadata = json.loads((package_root / "package.json").read_text(encoding="utf-8"))
patched = (
package_root / "dist" / "sandbox" / "linux-sandbox-utils.js"
).read_text(encoding="utf-8")
except (OSError, ValueError):
return False
srt = root / "node_modules" / ".bin" / "srt"
return (
Path(configured).expanduser().resolve()
if configured
else (_repo_root() / "runtime" / "native-sandbox").resolve()
metadata.get("version") == _PINNED_SRT_VERSION
and srt.is_file()
and os.access(srt, os.X_OK)
and "rootChildIsAllowedSymlink" in patched
and "--remount-ro" in patched
)
def _ensure_runtime_installed() -> Path:
"""Install the pinned SRT into user data from packaged lockfile assets."""
root = _runtime_root()
if _runtime_install_complete(root):
return root
npm = shutil.which("npm", path=_tool_path())
if not npm:
raise NativeSandboxUnavailable(
"native sandbox requires npm for its first deterministic runtime install"
)
assets = _runtime_assets_root()
for name in ("package.json", "package-lock.json", "patch_merged_usr.py"):
if not (assets / name).is_file():
raise NativeSandboxUnavailable(f"packaged native sandbox asset is missing: {name}")
root.parent.mkdir(parents=True, exist_ok=True)
lock = FileLock(str(root.parent / ".native-sandbox-install.lock"), timeout=180)
with lock:
if _runtime_install_complete(root):
return root
staging = root.parent / f".native-sandbox-install-{uuid.uuid4().hex}"
backup = root.parent / f".native-sandbox-backup-{uuid.uuid4().hex}"
try:
staging.mkdir(mode=0o700)
for name in ("package.json", "package-lock.json"):
shutil.copyfile(assets / name, staging / name)
result = subprocess.run(
[
npm,
"ci",
"--ignore-scripts",
"--omit=dev",
"--no-audit",
"--no-fund",
],
cwd=staging,
check=False,
capture_output=True,
text=True,
timeout=180,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()[-1000:]
raise NativeSandboxUnavailable(
f"native sandbox npm ci failed: {detail or 'unknown npm error'}"
)
from .native_sandbox_runtime.patch_merged_usr import patch_file
patch_file(
staging
/ "node_modules"
/ "@anthropic-ai"
/ "sandbox-runtime"
/ "dist"
/ "sandbox"
/ "linux-sandbox-utils.js"
)
if not _runtime_install_complete(staging):
raise NativeSandboxUnavailable(
"native sandbox runtime failed post-install verification"
)
if root.exists():
os.replace(root, backup)
try:
os.replace(staging, root)
except BaseException:
if backup.exists() and not root.exists():
os.replace(backup, root)
raise
shutil.rmtree(backup, ignore_errors=True)
return root
except NativeSandboxUnavailable:
raise
except (OSError, subprocess.TimeoutExpired) as exc:
raise NativeSandboxUnavailable(
"native sandbox runtime installation failed"
) from exc
finally:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
if backup.exists() and not root.exists():
os.replace(backup, root)
def _positive_int(name: str, default: int) -> int:
raw = os.getenv(name, str(default)).strip()
try:
@@ -185,7 +292,7 @@ def _assert_install_contract() -> NativeSandboxInstallation:
f"native sandbox does not support {platform.system() or sys.platform}"
)
root = _runtime_root()
root = _ensure_runtime_installed()
package_root = root / "node_modules" / "@anthropic-ai" / "sandbox-runtime"
package_json = package_root / "package.json"
srt = root / "node_modules" / ".bin" / "srt"
@@ -0,0 +1 @@
"""Packaged manifests and installer patch for the native sandbox runtime."""
+66
View File
@@ -0,0 +1,66 @@
{
"name": "evoscientist-native-sandbox-runtime",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "evoscientist-native-sandbox-runtime",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.73"
}
},
"node_modules/@anthropic-ai/sandbox-runtime": {
"version": "0.0.73",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.73.tgz",
"integrity": "sha512-F608iUirrCqwvInZYGRRgJWDQj0tt6fNVE9aPagpotLJ5LhC4JbrMFIIZww5MFjb+HRCkpE0+xdI79c30tdVYg==",
"license": "Apache-2.0",
"dependencies": {
"@pondwader/socks5-server": "^1.0.10",
"commander": "^12.1.0",
"node-forge": "^1.4.0",
"zod": "^3.24.1"
},
"bin": {
"srt": "dist/cli.js"
},
"engines": {
"node": ">=20.11.0"
}
},
"node_modules/@pondwader/socks5-server": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
"license": "MIT"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,8 @@
{
"name": "evoscientist-native-sandbox-runtime",
"private": true,
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.73"
}
}
@@ -0,0 +1,79 @@
from __future__ import annotations
import argparse
from pathlib import Path
_ORIGINAL = """ const rootSkip = new Set(['proc', 'dev', 'sys']);
for (const p of readConfig?.denyOnly || []) {
if (normalizePathForSandbox(p) === '/') {
for (const child of fs.readdirSync('/')) {
if (!rootSkip.has(child))
readDenyPaths.push('/' + child);
}
}
"""
_REPLACEMENT = """ const rootSkip = new Set(['proc', 'dev', 'sys']);
const rootChildIsAllowedSymlink = (childPath) => {
try {
if (!fs.lstatSync(childPath).isSymbolicLink())
return false;
const resolved = fs.realpathSync(childPath);
return readAllowPaths.some(allowPath => resolved === allowPath || resolved.startsWith(allowPath + '/'));
}
catch {
return false;
}
};
for (const p of readConfig?.denyOnly || []) {
if (normalizePathForSandbox(p) === '/') {
for (const child of fs.readdirSync('/')) {
const childPath = '/' + child;
if (!rootSkip.has(child) && !rootChildIsAllowedSymlink(childPath))
readDenyPaths.push(childPath);
}
}
"""
_TMPFS_ORIGINAL = """ args.push('--ro-bind', allowPath, allowPath);
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
}
}
}
"""
_TMPFS_REPLACEMENT = """ args.push('--ro-bind', allowPath, allowPath);
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
}
}
// A denyRead tmpfs must not become an unlisted writable location. Remount
// only the parent mount read-only; explicit writable child binds remain rw.
if (!allowedWritePaths.includes(normalizedPath)) {
args.push('--remount-ro', normalizedPath);
}
}
"""
def patch_file(path: Path) -> None:
source = path.read_text(encoding="utf-8")
if _REPLACEMENT in source or _TMPFS_REPLACEMENT in source:
raise RuntimeError("sandbox runtime merged-usr patch is already patched")
if source.count(_ORIGINAL) != 1:
raise RuntimeError("sandbox runtime merged-usr patch target does not match pinned source")
if source.count(_TMPFS_ORIGINAL) != 1:
raise RuntimeError("sandbox runtime read-only tmpfs patch target does not match pinned source")
patched = source.replace(_ORIGINAL, _REPLACEMENT)
patched = patched.replace(_TMPFS_ORIGINAL, _TMPFS_REPLACEMENT)
path.write_text(patched, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("path", type=Path)
args = parser.parse_args()
patch_file(args.path)
if __name__ == "__main__":
main()
+26 -20
View File
@@ -287,30 +287,36 @@ class PruningCheckpointer(AsyncSqliteSaver):
``agent_name`` key — by design, those rows belong to third-party
LangGraph users and must never be pruned by us.
Walk + DELETEs held under ``self.lock`` for atomicity with
concurrent ``aput()`` on the same thread.
Walk + DELETEs run in a ``BEGIN IMMEDIATE`` transaction. The database
write lock serializes independent checkpointer connections before
anchor selection, so a newly committed head cannot be deleted using
a stale retained-id set.
"""
keep = self._keep_per_ns
agent = AGENT_NAME
async with self.lock:
# ``writes`` table is checked inside ``_delete_outside`` so a
# legacy DB that only has ``checkpoints`` still gets pruned
# (writes DELETE silently skipped; checkpoints DELETE runs).
# The migration sweep depends on this — it walks legacy DBs
# that often pre-date the ``writes`` table entirely.
anchor_ids = await self._fetch_recent_checkpoint_ids(
thread_id, checkpoint_ns, agent, keep
)
if len(anchor_ids) < keep:
return # nothing to prune yet
extra_preserve = await self._walk_to_snapshot_ancestor(
thread_id, checkpoint_ns, anchor_ids[-1]
)
kept = set(anchor_ids) | extra_preserve
await self._delete_outside(thread_id, checkpoint_ns, agent, kept)
await self.conn.commit()
try:
await self.conn.execute("BEGIN IMMEDIATE")
# ``writes`` table is checked inside ``_delete_outside`` so a
# legacy DB that only has ``checkpoints`` still gets pruned
# (writes DELETE silently skipped; checkpoints DELETE runs).
# The migration sweep depends on this — it walks legacy DBs
# that often pre-date the ``writes`` table entirely.
anchor_ids = await self._fetch_recent_checkpoint_ids(
thread_id, checkpoint_ns, agent, keep
)
if len(anchor_ids) >= keep:
extra_preserve = await self._walk_to_snapshot_ancestor(
thread_id, checkpoint_ns, anchor_ids[-1]
)
kept = set(anchor_ids) | extra_preserve
await self._delete_outside(
thread_id, checkpoint_ns, agent, kept
)
await self.conn.commit()
except BaseException:
await self.conn.rollback()
raise
async def _fetch_recent_checkpoint_ids(
self,
+113 -13
View File
@@ -5,7 +5,11 @@ using Tavily for URL discovery and fetching full webpage content.
"""
import asyncio
import ipaddress
import math
import socket
from typing import Annotated, Literal
from urllib.parse import urljoin, urlsplit
import httpx
from langchain_core.tools import InjectedToolArg, tool
@@ -20,10 +24,13 @@ MAX_DISPLAY_TITLE_CHARS = 512
MAX_DISPLAY_URL_CHARS = 2_048
MAX_PAGE_CONTENT_CHARS = 4_000
MAX_SEARCH_RESULT_CHARS = 16_000
MAX_PAGE_RESPONSE_BYTES = 1_000_000
MAX_REDIRECTS = 5
_TRUNCATION_MARKER = "\n\n[page content truncated]"
_SEARCH_TRUNCATION_MARKER = (
"\n[search result content truncated to preserve all titles and URLs]"
)
_ALLOWED_WEB_PORTS = {"http": 80, "https": 443}
def _get_tavily_client() -> TavilyClient:
@@ -34,35 +41,128 @@ def _get_tavily_client() -> TavilyClient:
return _tavily_client
async def _resolve_public_web_target(url: str) -> tuple[str, str, int]:
"""Validate a web URL and return its host plus one verified public address."""
parsed = urlsplit(url)
scheme = parsed.scheme.lower()
if scheme not in _ALLOWED_WEB_PORTS:
raise ValueError("only HTTP(S) URLs are allowed")
if parsed.username is not None or parsed.password is not None:
raise ValueError("URL userinfo is not allowed")
host = parsed.hostname
if not host:
raise ValueError("URL host is required")
try:
port = parsed.port or _ALLOWED_WEB_PORTS[scheme]
except ValueError as exc:
raise ValueError("URL port is invalid") from exc
if port != _ALLOWED_WEB_PORTS[scheme]:
raise ValueError(f"URL port {port} is not allowed")
try:
literal = ipaddress.ip_address(host)
except ValueError:
loop = asyncio.get_running_loop()
answers = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
addresses = list(dict.fromkeys(answer[4][0] for answer in answers))
else:
addresses = [str(literal)]
if not addresses:
raise ValueError("URL host did not resolve")
parsed_addresses = [ipaddress.ip_address(address) for address in addresses]
if any(not address.is_global for address in parsed_addresses):
raise ValueError("URL host is not publicly routable")
return host, str(parsed_addresses[0]), port
def _pinned_request(url: str, host: str, address: str) -> tuple[httpx.URL, dict]:
"""Build a request target that cannot re-resolve the validated hostname."""
original = httpx.URL(url)
pinned = original.copy_with(host=address)
extensions = {"sni_hostname": host} if original.scheme == "https" else {}
return pinned, extensions
async def fetch_webpage_content(url: str, timeout: float = 10.0) -> str:
"""Fetch and convert webpage content to markdown.
"""Fetch and convert webpage content to markdown within a hard deadline.
Args:
url: URL to fetch
timeout: Request timeout in seconds
timeout: Total wall-clock and per-I/O timeout in seconds
Returns:
Webpage content as markdown
"""
headers = {
"Accept-Encoding": "identity",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/91.0.4472.124 Safari/537.36"
)
),
}
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, timeout=timeout)
response.raise_for_status()
content_type = response.headers.get("content-type", "").lower()
if not any(
allowed in content_type
for allowed in ("text/", "application/xhtml+xml")
):
return f"Error fetching content from {url}: unsupported content type {content_type or 'unknown'}"
return markdownify(response.text)
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and positive")
async with asyncio.timeout(timeout):
current_url = url
for redirect_count in range(MAX_REDIRECTS + 1):
host, address, _port = await _resolve_public_web_target(current_url)
pinned_url, extensions = _pinned_request(current_url, host, address)
request_headers = {**headers, "Host": host}
# The pinned IP is the HTTP pool origin. A fresh pool per hop
# prevents cross-host redirects that share an IP from reusing
# a TLS connection authenticated for the previous hostname.
async with httpx.AsyncClient(trust_env=False) as client:
async with client.stream(
"GET",
pinned_url,
headers=request_headers,
extensions=extensions,
timeout=timeout,
) as response:
if response.is_redirect:
location = response.headers.get("location")
if not location:
response.raise_for_status()
if redirect_count >= MAX_REDIRECTS:
raise ValueError("too many redirects")
current_url = urljoin(current_url, location)
continue
response.raise_for_status()
content_type = response.headers.get("content-type", "").lower()
content_encoding = response.headers.get(
"content-encoding", ""
).lower()
if content_encoding not in {"", "identity"}:
return (
f"Error fetching content from {url}: "
"compressed responses are not allowed"
)
if not any(
allowed in content_type
for allowed in ("text/", "application/xhtml+xml")
):
return (
f"Error fetching content from {url}: "
f"unsupported content type {content_type or 'unknown'}"
)
body = bytearray()
async for chunk in response.aiter_raw():
remaining = MAX_PAGE_RESPONSE_BYTES - len(body)
if remaining <= 0:
break
body.extend(chunk[:remaining])
return markdownify(
body.decode(response.encoding or "utf-8", errors="replace")
)
raise ValueError("too many redirects") # pragma: no cover
except TimeoutError:
return (
f"Error fetching content from {url}: "
f"wall-clock timeout after {timeout:g}s"
)
except Exception as e:
return f"Error fetching content from {url}: {e!s}"