diff --git a/agents/langchain-deepagents-code/dcode-launcher.sh b/agents/langchain-deepagents-code/dcode-launcher.sh index 225c2f3e58a..ffb55c17e68 100755 --- a/agents/langchain-deepagents-code/dcode-launcher.sh +++ b/agents/langchain-deepagents-code/dcode-launcher.sh @@ -113,6 +113,11 @@ fi _PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" _NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}" +# fetch_url cannot use its direct DNS-pinning transport inside OpenShell's +# proxy-only network namespace. Opt only this managed launch into the explicit +# trusted-proxy transport, using the same root-owned values as inference and +# shell egress. The managed package patch still ignores ambient proxy values. +export DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL="$_PROXY_URL" export HTTP_PROXY="$_PROXY_URL" export HTTPS_PROXY="$_PROXY_URL" export NO_PROXY="$_NO_PROXY_VAL" diff --git a/agents/langchain-deepagents-code/dependency-review.md b/agents/langchain-deepagents-code/dependency-review.md index 956a65bb113..1dca22f7823 100644 --- a/agents/langchain-deepagents-code/dependency-review.md +++ b/agents/langchain-deepagents-code/dependency-review.md @@ -14,6 +14,42 @@ Update it whenever `requirements.lock` changes. The Dockerfile installs this lockfile with `pip3 install --require-hashes`, so this review covers the exact package versions selected for the managed image install. +## Managed `fetch_url` Proxy Adapter + +Deep Agents Code `0.1.34` deliberately disables ambient proxies and resolves +destination DNS locally before pinning the address used by `fetch_url`. That is +the wrong transport inside a NemoClaw-managed sandbox: ordinary egress and +destination resolution must pass through the policy proxy, so the direct path +fails even when the same approved URL works through the managed route. + +NemoClaw owns the managed image, launchers, and policy boundary, but not the +hash-locked third-party `fetch_url` implementation. The exact-version build +patch therefore delegates only managed launches to a proxy URL independently +derived from the image's root-owned host and port files. The runtime rejects a +missing, unsafe, or mismatched file/environment contract, disables Requests' +ambient proxy, `NO_PROXY`, netrc, and CA discovery, and supplies the verified +proxy explicitly on every redirect hop. It separately validates the fixed, +root-owned CA-bundle mount injected into the sandbox and passes it as explicit +TLS transport trust; that bundle cannot select a proxy or authorize a +destination. Imports outside the managed launcher retain the upstream direct +DNS-pinning behavior. + +Redirect validation rejects authority userinfo (`user:password@host`). It does +not treat `@` or `:` in a path segment as credentials: RFC 3986 defines those +characters as ordinary path data, and coding tasks can legitimately encounter +them in repository refs or filenames. Focused redirect coverage pins that +distinction, while validation errors avoid echoing candidate URLs and the +policy proxy remains authoritative for every destination. + +Focused tests patch the released wheel, exercise managed and unmanaged paths, +reject forged proxy environments and malformed redirects, and prove that +credential-bearing URLs are not reflected. The live Deep Agents Code egress +check requires a nonempty 2xx response from an approved raw GitHub URL and +denial for an unapproved host, cloud metadata, and loopback. Remove this adapter +rather than refreshing it when a pinned Deep Agents Code release exposes a +supported policy-proxy transport with equivalent redirect and fail-closed +behavior. + ## Released Nemotron 3 Ultra Profile Deep Agents Code `0.1.34` pins `deepagents==0.7.0a6`, whose official wheel diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py index c6760db1fe6..724213c51ae 100644 --- a/agents/langchain-deepagents-code/managed-dcode-runtime.py +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -14,8 +14,10 @@ import re import stat import sys +from collections.abc import Callable from pathlib import Path -from urllib.parse import urlparse, urlsplit +from typing import Any +from urllib.parse import urljoin, urlparse, urlsplit _MANAGED_STATE_DIR = Path("/sandbox/.deepagents/.state") _AUTH_FILE = _MANAGED_STATE_DIR / "auth.json" @@ -24,6 +26,12 @@ _INFERENCE_BASE_URL_FILE = Path( "/usr/local/share/nemoclaw/dcode-inference-base-url" ) +_MANAGED_PROXY_HOST_FILE = Path( + "/usr/local/share/nemoclaw/dcode-proxy-host" +) +_MANAGED_PROXY_PORT_FILE = Path( + "/usr/local/share/nemoclaw/dcode-proxy-port" +) _AUTO_APPROVAL_FILE = Path( "/usr/local/share/nemoclaw/dcode-auto-approval" ) @@ -59,11 +67,22 @@ ) _OPENSHELL_ENV_PLACEHOLDER_PREFIX = "openshell:resolve:env:" _UPSTREAM_PROVIDER_ENV = "NEMOCLAW_UPSTREAM_PROVIDER" +_FETCH_URL_TRUSTED_PROXY_ENV = ( + "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL" +) +_MANAGED_FETCH_CA_BUNDLE_FILE = Path( + "/etc/openshell-tls/ca-bundle.pem" +) _MANAGED_ADAPTER_PROVIDER = "openai" _NVIDIA_DISPLAY_PROVIDER_ALIASES = frozenset( {"nvidia", "nvidia-prod", "nvidia-nim", "nvidia-router"} ) _DISPLAY_PROVIDER_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") +# Match the launchers' root-owned, image-baked proxy validator. Its deliberate +# RFC 1123 deviation permits underscores only for controlled internal/container +# aliases such as `proxy_name`; the cross-boundary cases in +# test/langchain-deepagents-code-proxy-launcher.test.ts prevent validator drift. +_MANAGED_PROXY_HOST = re.compile(r"[A-Za-z0-9._-]+") _MCP_SERVER_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}") _MCP_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}") _MCP_DNS_NAME = re.compile( @@ -903,6 +922,262 @@ def managed_inference_base_url() -> str: return value +def managed_fetch_proxy_url() -> str | None: + """Return the explicit OpenShell proxy delegated to managed ``fetch_url``. + + The variable is absent when this helper is imported outside the managed + launcher, in which case the upstream direct DNS-pinning transport remains + authoritative. When present, every conventional HTTP(S) proxy variable + must carry the same launcher-derived value. This prevents a mutable ambient + proxy or ``NO_PROXY`` rule from silently replacing the root-owned route. + """ + value = os.environ.get(_FETCH_URL_TRUSTED_PROXY_ENV) + if value is None: + return None + expected_proxy_url = _managed_fetch_proxy_url_from_files() + if ( + not value + or len(value) > 2048 + or value != value.strip() + or any(ord(character) < 32 for character in value) + ): + raise RuntimeError("managed fetch URL proxy is invalid") + try: + parsed = urlparse(value) + port = parsed.port + except ValueError as exc: + raise RuntimeError("managed fetch URL proxy is invalid") from exc + if ( + parsed.scheme != "http" + or not parsed.hostname + or port is None + or port < 1 + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.params + or parsed.query + or parsed.fragment + or _MANAGED_PROXY_HOST.fullmatch(parsed.hostname) is None + ): + raise RuntimeError("managed fetch URL proxy is invalid") + if value != expected_proxy_url: + raise RuntimeError( + "managed fetch URL proxy does not match root-owned proxy" + ) + for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"): + if os.environ.get(name) != value: + raise RuntimeError("managed fetch URL proxy does not match runtime proxy") + return value + + +def _read_managed_proxy_value(path: Path, label: str) -> str: + """Read one immutable proxy component from the managed image.""" + if not path.is_file() or path.is_symlink(): + raise RuntimeError(f"managed proxy {label} file is missing or unsafe") + try: + metadata = path.stat() + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise RuntimeError(f"managed proxy {label} file is unreadable") from exc + if ( + metadata.st_uid != _MANAGED_FILE_OWNER_UID + or stat.S_IMODE(metadata.st_mode) != 0o444 + ): + raise RuntimeError( + f"managed proxy {label} file has unsafe ownership or mode" + ) + value = raw.rstrip("\n") + if ( + not value + or len(value) > 2048 + or raw not in {value, f"{value}\n"} + or value != value.strip() + or any(ord(character) < 32 for character in value) + ): + raise RuntimeError(f"managed proxy {label} file has invalid contents") + return value + + +def _managed_fetch_proxy_url_from_files() -> str: + """Derive the trusted proxy URL independently from root-owned files.""" + host = _read_managed_proxy_value(_MANAGED_PROXY_HOST_FILE, "host") + port = _read_managed_proxy_value(_MANAGED_PROXY_PORT_FILE, "port") + if _MANAGED_PROXY_HOST.fullmatch(host) is None: + raise RuntimeError("managed proxy host file has invalid contents") + if ( + re.fullmatch(r"[0-9]{1,5}", port) is None + or not 1 <= int(port, 10) <= 65535 + ): + raise RuntimeError("managed proxy port file has invalid contents") + return f"http://{host}:{port}" + + +def _managed_fetch_ca_bundle() -> tuple[int, str]: + """Open and validate fixed OpenShell TLS trust without a pathname race.""" + path = _MANAGED_FETCH_CA_BUNDLE_FILE + no_follow = getattr(os, "O_NOFOLLOW", None) + if no_follow is None: + raise RuntimeError("managed fetch CA bundle is invalid") + flags = os.O_RDONLY | no_follow | getattr(os, "O_CLOEXEC", 0) + try: + descriptor = os.open(path, flags) + except FileNotFoundError: + raise RuntimeError("managed fetch CA bundle is unavailable") from None + except OSError: + raise RuntimeError("managed fetch CA bundle is invalid") from None + + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != _MANAGED_FILE_OWNER_UID + or stat.S_IMODE(metadata.st_mode) & 0o022 + or metadata.st_size <= 0 + ): + raise RuntimeError("managed fetch CA bundle is invalid") + descriptor_path = next( + ( + candidate + for root in ("/proc/self/fd", "/dev/fd") + if os.path.exists(candidate := f"{root}/{descriptor}") + ), + None, + ) + if descriptor_path is None: + raise RuntimeError("managed fetch CA bundle is invalid") + return descriptor, descriptor_path + except OSError: + os.close(descriptor) + raise RuntimeError("managed fetch CA bundle is invalid") from None + except RuntimeError: + os.close(descriptor) + raise + + +def _close_managed_fetch_ca_bundle(descriptor: int) -> None: + try: + os.close(descriptor) + except OSError: + # Closing the read-only trust snapshot cannot expand authority. + pass + + +def _rewind_managed_fetch_ca_bundle(descriptor: int) -> None: + """Reset fd-backed trust before each synchronous transport read.""" + try: + os.lseek(descriptor, 0, os.SEEK_SET) + except OSError: + raise RuntimeError("managed fetch CA bundle is invalid") from None + + +def managed_fetch_with_redirects( + url: str, + *, + timeout: int, + max_redirects: int, + original_fetch: Callable[..., Any], + validation_error: type[ValueError], +) -> Any: + """Fetch through only the launcher-delegated OpenShell proxy. + + Outside the managed launcher, preserve the pinned upstream transport. In + the managed image, avoid forbidden direct DNS while keeping requests' + ambient proxy discovery and ``NO_PROXY`` disabled. OpenShell's proxy then + remains the authoritative network-policy and SSRF boundary for every hop. + """ + try: + proxy_url = managed_fetch_proxy_url() + except RuntimeError as exc: + # Keep runtime-integrity failures inside fetch_url's structured + # validation result instead of surfacing an opaque tool exception. + raise validation_error(str(exc)) from exc + if proxy_url is None: + return original_fetch(url, timeout=timeout) + + try: + import requests + except ImportError: + # Keep an optional dependency failure inside fetch_url's structured + # validation result without exposing import paths or stack details. + raise validation_error( + "managed fetch transport dependency is unavailable" + ) from None + try: + ca_descriptor, ca_bundle = _managed_fetch_ca_bundle() + except RuntimeError as exc: + raise validation_error(str(exc)) from None + + def validate_url(candidate: str) -> None: + try: + parsed = urlparse(candidate) + hostname = parsed.hostname + # Force malformed ports through the same structured validation path + # even though requests, rather than this helper, uses the value. + _ = parsed.port + except ValueError as exc: + raise validation_error("URL is malformed") from exc + if parsed.scheme not in {"http", "https"}: + raise validation_error( + f"URL scheme not allowed: {parsed.scheme!r} (must be http or https)" + ) + if not hostname: + raise validation_error("URL is missing a hostname") + # RFC 3986 credentials are authority userinfo, exposed by username and + # password. An `@` or `:` after the authority is ordinary path data + # (including valid repository refs/files), never authentication; + # rejecting that shape would create false positives. These validation + # errors never echo the candidate URL, and the explicit OpenShell proxy + # remains the destination-policy and SSRF authority for every hop. + if parsed.username is not None or parsed.password is not None: + raise validation_error("URL credentials are not allowed") + try: + hostname.encode("idna").decode("ascii") + except UnicodeError: + raise validation_error("URL hostname is not valid IDNA") from None + + current_url = url + proxies = {"http": proxy_url, "https": proxy_url} + try: + with requests.Session() as session: + # Disable every requests environment-derived session setting, including + # proxy/NO_PROXY, netrc, and CA-bundle discovery. Each request receives + # the sole root-verified proxy mapping explicitly below. The separately + # selected CA bundle establishes TLS transport trust only; it cannot + # choose a proxy or authorize a destination under OpenShell policy. + session.trust_env = False + for _hop in range(max_redirects + 1): + validate_url(current_url) + try: + _rewind_managed_fetch_ca_bundle(ca_descriptor) + except RuntimeError as exc: + raise validation_error(str(exc)) from None + response = session.get( + current_url, + timeout=timeout, + headers={"User-Agent": "Mozilla/5.0 (compatible; DeepAgents/1.0)"}, + allow_redirects=False, + proxies=proxies, + verify=ca_bundle, + ) + if 300 <= response.status_code < 400: + location = response.headers.get("Location") + if not location: + raise validation_error( + f"Redirect response (status {response.status_code}) is missing a Location header" + ) + current_url = urljoin(current_url, location) + continue + response.raise_for_status() + return response + + raise requests.exceptions.TooManyRedirects( + f"Exceeded {max_redirects} redirects" + ) + finally: + _close_managed_fetch_ca_bundle(ca_descriptor) + + def _disabled_auto_approval(reason: str) -> str: if os.environ.get("NEMOCLAW_DEBUG") == "1": print( @@ -994,6 +1269,7 @@ def assert_safe_runtime() -> None: """Reject unmanaged runtime credentials before dcode bootstraps settings.""" _assert_safe_environment() _assert_safe_auth_state() + managed_fetch_proxy_url() base_url = managed_inference_base_url() os.environ["OPENAI_BASE_URL"] = base_url os.environ["NEMOCLAW_INFERENCE_BASE_URL"] = base_url diff --git a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py index 924e9a17fc7..e3be96a9d8a 100644 --- a/agents/langchain-deepagents-code/patch-managed-deepagents-code.py +++ b/agents/langchain-deepagents-code/patch-managed-deepagents-code.py @@ -480,7 +480,7 @@ def _preview_dotenv_environ(*, start_path=None) -> dict[str, str]: def _load_dotenv(*, start_path=None, refresh_loaded=False) -> bool: - """Disable project and global dotenv loading in the managed image.""" + """Disable all dotenv loading so it cannot supply the trusted fetch proxy.""" del start_path, refresh_loaded _dotenv_loaded_values.clear() return False @@ -518,6 +518,34 @@ def _get_provider_kwargs(provider: str, *, model_name: str | None = None) -> dic } ''' +# Source-of-truth boundary: upstream Deep Agents Code 0.1.34 resolves and pins +# destination DNS locally, then disables environment proxies. That is a sound +# standalone SSRF defense but cannot operate in OpenShell's proxy-only network +# namespace, where direct DNS and direct target connections are rejected. The +# managed launcher supplies an explicit, root-owned proxy URL. `trust_env=False` +# disables every Requests environment-derived session setting (proxy/NO_PROXY, +# netrc, and CA discovery); each hop receives only the explicit proxy mapping +# and separately validated fixed CA bundle. The proxy's network policy and SSRF +# checks remain authoritative. +TOOLS_PATCH = r''' + +# NemoClaw-managed Deep Agents Code hardening v2. +_nemoclaw_original_fetch_with_redirects = _fetch_with_redirects + + +def _fetch_with_redirects(url: str, *, timeout: int): + """Use only the launcher-delegated OpenShell proxy when configured.""" + from deepagents_code._nemoclaw_managed import managed_fetch_with_redirects + + return managed_fetch_with_redirects( + url, + timeout=timeout, + max_redirects=_MAX_FETCH_REDIRECTS, + original_fetch=_nemoclaw_original_fetch_with_redirects, + validation_error=_UrlValidationError, + ) +''' + MODEL_CONFIG_PATCH = r''' # NemoClaw-managed Deep Agents Code hardening v2. @@ -1132,6 +1160,20 @@ def _top_level_functions(tree: ast.Module) -> set[str]: } +def _top_level_symbols(tree: ast.Module) -> set[str]: + symbols = _top_level_functions(tree) + for node in tree.body: + if isinstance(node, ast.ClassDef): + symbols.add(node.name) + elif isinstance(node, ast.Assign): + symbols.update( + target.id for target in node.targets if isinstance(target, ast.Name) + ) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + symbols.add(node.target.id) + return symbols + + def _class_methods(tree: ast.Module, class_name: str) -> set[str]: for node in tree.body: if isinstance(node, ast.ClassDef) and node.name == class_name: @@ -1151,6 +1193,12 @@ def _require_functions(path: Path, text: str, names: set[str]) -> ast.Module: return tree +def _require_symbols(path: Path, tree: ast.Module, names: set[str]) -> None: + missing = names - _top_level_symbols(tree) + if missing: + raise RuntimeError(f"Required upstream symbols missing in {path}: {sorted(missing)}") + + def _require_methods( path: Path, text: str, class_name: str, names: set[str] ) -> ast.Module: @@ -1235,6 +1283,7 @@ def main() -> None: "app": root / "app.py", "auth_store": root / "auth_store.py", "config": root / "config.py", + "tools": root / "tools.py", "model_config": root / "model_config.py", "agent": root / "agent.py", "update_check": root / "update_check.py", @@ -1310,6 +1359,7 @@ def main() -> None: for name, patch in ( ("entrypoint", ENTRYPOINT_PATCH), ("main", MAIN_PATCH), + ("tools", TOOLS_PATCH), ("app", APP_PATCH), ("approval", APPROVAL_PATCH), ("agent", AGENT_PATCH), @@ -1384,6 +1434,12 @@ def main() -> None: "_tracing_enabled", }, ) + tools_tree = _require_functions( + paths["tools"], texts["tools"], {"_fetch_with_redirects"} + ) + _require_symbols( + paths["tools"], tools_tree, {"_MAX_FETCH_REDIRECTS", "_UrlValidationError"} + ) _require_methods( paths["model_config"], texts["model_config"], @@ -1503,6 +1559,7 @@ def main() -> None: paths["auth_store"], texts["auth_store"], AUTH_STORE_PATCH ) transformed["config"] = _append_patch(paths["config"], texts["config"], CONFIG_PATCH) + transformed["tools"] = _append_patch(paths["tools"], texts["tools"], TOOLS_PATCH) transformed["model_config"] = _append_patch( paths["model_config"], texts["model_config"], MODEL_CONFIG_PATCH ) diff --git a/agents/langchain-deepagents-code/policy-additions.yaml b/agents/langchain-deepagents-code/policy-additions.yaml index 129f4501e16..a0a739f47cd 100644 --- a/agents/langchain-deepagents-code/policy-additions.yaml +++ b/agents/langchain-deepagents-code/policy-additions.yaml @@ -65,6 +65,17 @@ network_policies: - host: api.github.com port: 443 access: full + # GitHub's API and HTML pages link repository file bodies through this + # separate host. A general coding agent must follow repository, ref, and + # file paths that vary by task, so the host-wide path is intentional while + # methods remain read-only. The broader endpoints above support git work. + - host: raw.githubusercontent.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: HEAD, path: "/**" } binaries: - { path: /usr/bin/git } - { path: /usr/local/bin/dcode } diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index bf3cfaebcb1..0516b6b3d8b 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -118,6 +118,14 @@ fi _PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" _NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}" +# Deep Agents Code 0.1.34 intentionally ignores environment proxies in +# fetch_url so it can pin direct DNS results against rebinding. OpenShell's +# sandbox instead requires all ordinary egress, including DNS resolution for a +# destination, to stay behind its policy proxy. This explicit variable opts the +# managed package patch into that trusted proxy boundary without teaching the +# upstream tool to trust arbitrary ambient HTTP_PROXY values. It is derived +# only from the root-owned image files validated above. +export DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL="$_PROXY_URL" export HTTP_PROXY="$_PROXY_URL" export HTTPS_PROXY="$_PROXY_URL" export NO_PROXY="$_NO_PROXY_VAL" @@ -154,6 +162,9 @@ prepare_runtime_env() { printf '%s\n' 'export LANGCHAIN_TRACING_V2=false' printf '%s\n' 'export DEEPAGENTS_CODE_OFFLINE=1' printf '%s\n' 'export DEEPAGENTS_CODE_RIPGREP_INSTALLER=system' + # Intentionally omit the trusted proxy when unset: its absence signals + # unmanaged mode, where the upstream fetch transport remains authoritative. + write_export_if_set DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL # shellcheck disable=SC2016 printf '%s\n' 'export DEEPAGENTS_CODE_OPENAI_API_KEY="${DEEPAGENTS_CODE_OPENAI_API_KEY:-nemoclaw-managed-inference}"' # shellcheck disable=SC2016 diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 6b92282125b..950f8a1bdab 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -91,8 +91,11 @@ Use `$$nemoclaw policy-list` or `openshell policy get --base | Policy | Endpoints | Binaries | Rules | | --- | --- | --- | --- | | `managed_inference` | `inference.local:443` | `/usr/local/bin/dcode`, `/opt/venv/bin/python3*`, `/opt/venv/lib/python3.13/**` | POST to OpenAI-compatible completion and embedding paths, GET to model listings | -| `github` | `github.com:443`, `api.github.com:443` | `/usr/bin/git`, `/usr/local/bin/dcode`, `/opt/venv/bin/python3*` | Full access to the listed hosts | -| `pypi` | `pypi.org:443`, `files.pythonhosted.org:443` | `/opt/venv/bin/python3*`, `/opt/venv/bin/pip3*` | GET and HEAD for package installation | +| `github` | `github.com:443`, `api.github.com:443`, `raw.githubusercontent.com:443` | `/usr/bin/git`, `/usr/local/bin/dcode`, `/opt/venv/bin/python3*` | Full access to `github.com` and `api.github.com`; GET and HEAD only to `raw.githubusercontent.com` | +| `pypi` | `pypi.org:443`, `files.pythonhosted.org:443` | `/opt/venv/bin/python3*`, `/opt/venv/bin/pip3*` | GET for package installation | + +The separate `raw.githubusercontent.com` route lets Deep Agents Code follow GitHub file links and read repository source through its managed `fetch_url` tool. +Repository, ref, and file path segments vary by task, so the route covers the host while limiting requests to read-only GET and HEAD methods and the listed managed binaries. diff --git a/src/lib/actions/sandbox/exec-policy-hint-detection.test.ts b/src/lib/actions/sandbox/exec-policy-hint-detection.test.ts index c76742caaa3..a4a69a51245 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-detection.test.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-detection.test.ts @@ -20,6 +20,8 @@ const SSH_RELAY_INFO_LINE = // fixture strict: speculative wording variants would widen false positives. const PROXY_JSON_LINE = '{"detail":"CONNECT example.com:443 not permitted by policy","error":"policy_denied"}'; +const INTERLEAVED_CURL_PROXY_JSON_LINE = + 'curl: (22) The reque{"detail":"POST host.openshell.internal:4318/v1/traces not permitted by policy","error":"policy_denied"}sted URL returned error: 403'; // A denial timestamp of 1783046573.602s parses to 1783046573602ms. Anchor the // command-start stamps around it to exercise the recency window. @@ -40,6 +42,7 @@ describe("isPolicyDenialLine (#5978)", () => { true, ], ["proxy JSON policy_denied body", PROXY_JSON_LINE, true], + ["proxy JSON interleaved with curl stderr", INTERLEAVED_CURL_PROXY_JSON_LINE, true], [ "forward proxy host denial body", '{"error":"policy_denied","detail":"POST example.com:4318/v1/traces not permitted by policy"}', diff --git a/src/lib/actions/sandbox/exec-policy-hint-detection.ts b/src/lib/actions/sandbox/exec-policy-hint-detection.ts index 3a84cbba205..1826d67c19a 100644 --- a/src/lib/actions/sandbox/exec-policy-hint-detection.ts +++ b/src/lib/actions/sandbox/exec-policy-hint-detection.ts @@ -86,19 +86,52 @@ function isStructuredProxyPolicyDenialDetail(detail: string): boolean { // CONNECT or forward-HTTP 403 JSON while the child tool receives opaque text. // - Source boundary/fix constraint: the payload is emitted by the external // OpenShell proxy, so NemoClaw can only translate it after exec returns. -// - Regression coverage: prefixed, unprefixed, malformed, and near-miss JSON -// payloads live in exec-policy-hint-detection.test.ts. +// curl may interleave that response body with its own stderr text, so extract +// one complete JSON object without trusting surrounding output. +// - Regression coverage: prefixed, suffixed, unprefixed, malformed, and +// near-miss JSON payloads live in exec-policy-hint-detection.test.ts. // - Removal condition: delete this fallback when OpenShell provides a typed // exec-denial result. Until then, require both the exact error code and the // complete safely bounded CONNECT or forward-HTTP detail so unrelated JSON // cannot match. The forward forms mirror OpenShell v0.0.72's endpoint, path, // and L7 policy denial messages. +function firstJsonObject(line: string): string | null { + const start = line.indexOf("{"); + if (start === -1) return null; + + let depth = 0; + let escaped = false; + let inString = false; + for (let index = start; index < line.length; index += 1) { + const character = line[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + } else if (character === "{") { + depth += 1; + } else if (character === "}") { + depth -= 1; + if (depth === 0) return line.slice(start, index + 1); + } + } + return null; +} + function isStructuredJsonPolicyDenial(line: string): boolean { if (line.length > MAX_STRUCTURED_PROXY_LINE_LENGTH) return false; - const jsonStart = line.indexOf("{"); - if (jsonStart === -1) return false; + const jsonObject = firstJsonObject(line); + if (jsonObject === null) return false; try { - const parsed: unknown = JSON.parse(line.slice(jsonStart)); + const parsed: unknown = JSON.parse(jsonObject); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false; const payload = parsed as Record; const keys = Object.keys(payload); diff --git a/test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh b/test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh index a038ed31aaf..0adc0ed16d7 100755 --- a/test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh +++ b/test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh @@ -138,6 +138,87 @@ expect_blocked() { fi } +fetch_url_probe_source() { + cat <<'PY' +import os +import sys + +from deepagents_code.tools import fetch_url + +DENIAL_MARKERS = ( + 'access denied', + 'blocked by', + 'connection forbidden', + 'egress denied', + 'network policy', + 'operation not permitted', + 'permission denied', + 'policy denied', + 'tunnel connection failed', +) + +if not os.environ.get('DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL'): + print('FETCH_ERROR:managed fetch proxy delegation is absent') + raise SystemExit(0) + +result = fetch_url(sys.argv[1], timeout=8) +status = result.get('status_code') +content_length = result.get('content_length') +if ( + isinstance(status, int) + and 200 <= status < 300 + and isinstance(content_length, int) + and content_length > 0 +): + print(f'FETCH_SUCCESS:{status}:{content_length}') +else: + details = str(result.get('error', result)) + lowered = details.lower() + if any(marker in lowered for marker in DENIAL_MARKERS): + print(f'FETCH_BLOCKED:{details}') + else: + print(f'FETCH_ERROR:{details}') +PY +} + +fetch_url_probe() { + local url="$1" + local encoded remote_cmd + if [ -n "${NEMOCLAW_E2E_FETCH_URL_PROBE_FIXTURE+x}" ]; then + printf '%s\n' "$NEMOCLAW_E2E_FETCH_URL_PROBE_FIXTURE" + return 0 + fi + encoded="$(fetch_url_probe_source | base64 | tr -d '\n')" + remote_cmd=". /tmp/nemoclaw-proxy-env.sh && /opt/venv/bin/python3 -c \"\$(printf '%s' ${encoded@Q} | base64 -d)\" ${url@Q}" + sandbox_exec "$remote_cmd" +} + +expect_fetch_reached() { + local label="$1" + local url="$2" + local output + output="$(fetch_url_probe "$url" || true)" + if echo "$output" | grep -Eq 'FETCH_SUCCESS:2[0-9]{2}:[1-9][0-9]*'; then + pass "Deep Agents fetch_url can reach approved ${label} host through the managed proxy" + else + fail_test "Deep Agents fetch_url could not reach approved ${label} host: $output" + fi +} + +expect_fetch_blocked() { + local label="$1" + local url="$2" + local output + output="$(fetch_url_probe "$url" || true)" + if echo "$output" | grep -q "FETCH_BLOCKED:" && ! echo "$output" | grep -q "FETCH_SUCCESS:"; then + pass "Deep Agents fetch_url cannot reach ${label} without explicit policy" + elif echo "$output" | grep -q "FETCH_SUCCESS:"; then + fail_test "Deep Agents fetch_url reached ${label} unexpectedly: $output" + else + fail_test "Deep Agents fetch_url probe for ${label} lacked denial evidence: $output" + fi +} + PASSED=0 FAILED=0 @@ -166,6 +247,37 @@ if [ "${NEMOCLAW_E2E_PYTHON_EGRESS_SELF_TEST:-}" = "probe-command-shape" ]; then exit 0 fi +if [ "${NEMOCLAW_E2E_PYTHON_EGRESS_SELF_TEST:-}" = "fetch-probe-command-shape" ]; then + sandbox_exec() { + case "$1" in + *$'\n'*) + printf '%s\n' "NEWLINE_IN_COMMAND" + return 1 + ;; + *) + printf '%s\n' "NO_NEWLINE_IN_FETCH_COMMAND" + return 0 + ;; + esac + } + fetch_url_probe "https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/README.md" + exit 0 +fi + +if [ "${NEMOCLAW_E2E_PYTHON_EGRESS_SELF_TEST:-}" = "fetch-success-classification" ]; then + expect_fetch_reached "fixture host" "https://approved.example/" + printf '%s\n' "${PREFIX}: $PASSED passed, $FAILED failed" + [ "$FAILED" -eq 0 ] || exit 1 + exit 0 +fi + +if [ "${NEMOCLAW_E2E_PYTHON_EGRESS_SELF_TEST:-}" = "fetch-blocked-classification" ]; then + expect_fetch_blocked "fixture host" "https://blocked.example/" + printf '%s\n' "${PREFIX}: $PASSED passed, $FAILED failed" + [ "$FAILED" -eq 0 ] || exit 1 + exit 0 +fi + cleanup_project_venv() { sandbox_exec "rm -rf ${PROJECT_VENV@Q}" >/dev/null || true } @@ -206,6 +318,17 @@ expect_blocked "arbitrary Python" "LangSmith" "https://api.smith.langchain.com/" expect_blocked "arbitrary Python" "MCP hosts" "https://modelcontextprotocol.io/" expect_blocked "arbitrary Python" "unapproved hosts" "https://example.com/" +# Exercise the actual Deep Agents fetch_url transport. Unlike urllib, upstream +# fetch_url disables ambient proxies to pin direct DNS results; the managed +# image patch must instead force the root-owned OpenShell proxy without honoring +# NO_PROXY. The raw GitHub path covers the separate read-only policy endpoint. +expect_fetch_reached \ + "raw GitHub" \ + "https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/README.md" +expect_fetch_blocked "unapproved hosts" "https://example.com/" +expect_fetch_blocked "instance metadata" "https://169.254.169.254/latest/meta-data/" +expect_fetch_blocked "sandbox loopback" "https://127.0.0.1/" + # Exercise the writable-project-venv allowlist entries directly. The managed # /opt/venv Python creates the project venv, then the probes run through the # /sandbox/.../bin/python3 executable path that policy-additions.yaml allows diff --git a/test/e2e/support/deepagents-observability-contract.test.ts b/test/e2e/support/deepagents-observability-contract.test.ts index 1eef481cd5d..7fe48b69da7 100644 --- a/test/e2e/support/deepagents-observability-contract.test.ts +++ b/test/e2e/support/deepagents-observability-contract.test.ts @@ -286,6 +286,15 @@ describe("Deep Agents observability policy proof", () => { }); expect(proxyDenial.status, proxyDenial.stderr).toBe(0); expect(proxyDenial.stdout.trim()).toBe("policy-denied"); + + const interleavedCurlDenial = spawnSync(tsx, [helper, "denial-state"], { + encoding: "utf8", + env: { PATH: process.env.PATH }, + input: + 'curl: (22) The reque{"detail":"POST host.openshell.internal:4318/v1/traces not permitted by policy","error":"policy_denied"}sted URL returned error: 403\n', + }); + expect(interleavedCurlDenial.status, interleavedCurlDenial.stderr).toBe(0); + expect(interleavedCurlDenial.stdout.trim()).toBe("policy-denied"); }); }); diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 1e86941d1ad..2fa5405e8e3 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -265,6 +265,73 @@ describe("P0-E cloud-experimental parity guardrails", () => { ); }); + it("keeps Deep Agents fetch_url probe command single-line for OpenShell exec", () => { + const result = spawnSync( + "bash", + [ + path.join( + process.cwd(), + "test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh", + ), + ], + { + encoding: "utf8", + env: { + NEMOCLAW_E2E_PYTHON_EGRESS_SELF_TEST: "fetch-probe-command-shape", + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("NO_NEWLINE_IN_FETCH_COMMAND"); + }); + + it.each([ + [ + "accepts an explicit non-empty success response", + "fetch-success-classification", + "FETCH_SUCCESS:200:1234", + 0, + "1 passed", + ], + [ + "accepts explicit denial evidence", + "fetch-blocked-classification", + "FETCH_BLOCKED:network policy denied", + 0, + "1 passed", + ], + [ + "rejects an unclassified fetch error", + "fetch-blocked-classification", + "FETCH_ERROR:opaque 403", + 1, + "lacked denial evidence", + ], + ] as const)("%s from the fetch_url probe", (_label, selfTest, fixture, status, expected) => { + const result = spawnSync( + "bash", + [ + path.join( + process.cwd(), + "test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh", + ), + ], + { + encoding: "utf8", + env: { + NEMOCLAW_E2E_PYTHON_EGRESS_SELF_TEST: selfTest, + NEMOCLAW_E2E_FETCH_URL_PROBE_FIXTURE: fixture, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + }, + ); + + expect(result.status).toBe(status); + expect(`${result.stdout}\n${result.stderr}`).toContain(expected); + }); + it("keeps Deep Agents secret-boundary probe command single-line for OpenShell exec", () => { const result = spawnSync( "bash", diff --git a/test/helpers/langchain-deepagents-code-headless.ts b/test/helpers/langchain-deepagents-code-headless.ts index 13d280dbe35..c40d01fa2fc 100644 --- a/test/helpers/langchain-deepagents-code-headless.ts +++ b/test/helpers/langchain-deepagents-code-headless.ts @@ -27,6 +27,7 @@ export const PROXY_URL_ENV_NAMES = [ "https_proxy", ] as const; export const NO_PROXY_ENV_NAMES = ["NO_PROXY", "no_proxy"] as const; +export const TRUSTED_FETCH_PROXY_ENV_NAME = "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL" as const; const CLEARED_PROXY_ENV_NAMES = ["ALL_PROXY", "all_proxy", "OPENAI_PROXY"] as const; export const TRACING_ENABLE_ENV_NAMES = [ "DEEPAGENTS_CODE_LANGSMITH_TRACING", @@ -51,8 +52,10 @@ export function makeStartScriptFixture( const scriptPath = path.join(tempDir, "start.sh"); const hostFile = path.join(tempDir, "trusted-proxy-host"); const portFile = path.join(tempDir, "trusted-proxy-port"); + const markerDir = path.join(tempDir, "persistent-dcode-state"); expect(original).toContain("local target=/tmp/nemoclaw-proxy-env.sh"); expect(original).toContain('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"'); + expect(original).toContain("local marker_dir=/sandbox/.deepagents"); const fixture = original .replace( 'readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host"', @@ -70,11 +73,14 @@ export function makeStartScriptFixture( .replace( 'tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"', `tmp="$(mktemp "${tempDir}/nemoclaw-proxy-env.XXXXXX")"`, - ); + ) + .replace("local marker_dir=/sandbox/.deepagents", `local marker_dir="${markerDir}"`); expect(fixture).toContain(`local target="${envFile}"`); expect(fixture).toContain(`tmp="$(mktemp "${tempDir}/nemoclaw-proxy-env.XXXXXX")"`); expect(fixture).not.toContain("local target=/tmp/nemoclaw-proxy-env.sh"); expect(fixture).not.toContain('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"'); + expect(fixture).toContain(`local marker_dir="${markerDir}"`); + expect(fixture).not.toContain("local marker_dir=/sandbox/.deepagents"); fs.writeFileSync(hostFile, "10.200.0.1\n", "utf8"); fs.writeFileSync(portFile, "3128\n", "utf8"); fs.chmodSync(hostFile, 0o444); @@ -135,6 +141,7 @@ export function runStartScriptProxyProbe( const probe = [ ...[ ...PROXY_URL_ENV_NAMES, + TRUSTED_FETCH_PROXY_ENV_NAME, ...NO_PROXY_ENV_NAMES, ...CLEARED_PROXY_ENV_NAMES, ...TRACING_ENABLE_ENV_NAMES, @@ -146,6 +153,7 @@ export function runStartScriptProxyProbe( '. "$NEMOCLAW_TEST_PROXY_ENV"', ...[ ...PROXY_URL_ENV_NAMES, + TRUSTED_FETCH_PROXY_ENV_NAME, ...NO_PROXY_ENV_NAMES, ...CLEARED_PROXY_ENV_NAMES, ...TRACING_ENABLE_ENV_NAMES, diff --git a/test/helpers/langchain-deepagents-code-patch-fixture.ts b/test/helpers/langchain-deepagents-code-patch-fixture.ts index ce933576a94..8ec1ac36c3d 100644 --- a/test/helpers/langchain-deepagents-code-patch-fixture.ts +++ b/test/helpers/langchain-deepagents-code-patch-fixture.ts @@ -278,6 +278,26 @@ def _parse_interpreter_ptc(raw): def _get_provider_kwargs(provider, *, model_name=None): del provider, model_name return {"api_key": "unsafe", "base_url": "https://unsafe.example"} +`, + ); + writeFixtureFile( + packageDir, + "tools.py", + ` +from __future__ import annotations + +from urllib.parse import urljoin, urlparse + +_ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) +_MAX_FETCH_REDIRECTS = 5 + + +class _UrlValidationError(ValueError): + pass + + +def _fetch_with_redirects(url, *, timeout): + return {"transport": "direct", "url": url, "timeout": timeout} `, ); writeFixtureFile( diff --git a/test/langchain-deepagents-code-direct-module-patch.test.ts b/test/langchain-deepagents-code-direct-module-patch.test.ts index 2a4af1afa88..f6f764d1fbd 100644 --- a/test/langchain-deepagents-code-direct-module-patch.test.ts +++ b/test/langchain-deepagents-code-direct-module-patch.test.ts @@ -43,6 +43,7 @@ describe("LangChain Deep Agents Code managed package patch", () => { "app.py", "auth_store.py", "config.py", + "tools.py", "model_config.py", "agent.py", "update_check.py", @@ -86,6 +87,7 @@ describe("LangChain Deep Agents Code managed package patch", () => { it.each([ ["entrypoint", "__main__.py", 'os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] = "1"'], ["main", "main.py", 'os.environ["LANGGRAPH_CLI_NO_ANALYTICS"] = "1"'], + ["tools", "tools.py", "_nemoclaw_original_fetch_with_redirects = _fetch_with_redirects"], [ "agent", "agent.py", @@ -1077,11 +1079,18 @@ async def validate(): project = Path(${JSON.stringify(tempDir)}) / "project" project.mkdir() - (project / ".env").write_text("PROJECT_API_KEY=should-not-load\\n", encoding="utf-8") + (project / ".env").write_text( + "PROJECT_API_KEY=should-not-load\\n" + "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL=http://attacker.internal:4444\\n", + encoding="utf-8", + ) os.chdir(project) assert config._load_dotenv() is False assert "PROJECT_API_KEY" not in os.environ + assert "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL" not in os.environ assert "PROJECT_API_KEY" not in config._preview_dotenv_environ() + assert "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL" not in config._preview_dotenv_environ() + assert _nemoclaw_managed.managed_fetch_proxy_url() is None for name in ( "LANGSMITH_TRACING", "LANGSMITH_TRACING_V2", @@ -1350,5 +1359,57 @@ print("managed-auto-approval-ok") }); expect(shapeResult.status).not.toBe(0); expect(shapeResult.stderr).toContain("_prompt_launch_tavily"); + + const missingFetch = createPackageFixture(); + const toolsPath = path.join(missingFetch, "deepagents_code", "tools.py"); + fs.writeFileSync( + toolsPath, + fs + .readFileSync(toolsPath, "utf8") + .replace("def _fetch_with_redirects(", "def _renamed_fetch_with_redirects("), + "utf8", + ); + const fetchShapeResult = spawnSync("python3", [patcher], { + env: { PATH: process.env.PATH, PYTHONPATH: missingFetch }, + encoding: "utf8", + }); + expect(fetchShapeResult.status).not.toBe(0); + expect(fetchShapeResult.stderr).toContain("_fetch_with_redirects"); + + const missingRedirectLimit = createPackageFixture(); + const missingRedirectLimitPath = path.join(missingRedirectLimit, "deepagents_code", "tools.py"); + fs.writeFileSync( + missingRedirectLimitPath, + fs + .readFileSync(missingRedirectLimitPath, "utf8") + .replace("_MAX_FETCH_REDIRECTS = 5", "_RENAMED_MAX_FETCH_REDIRECTS = 5"), + "utf8", + ); + const redirectLimitResult = spawnSync("python3", [patcher], { + env: { PATH: process.env.PATH, PYTHONPATH: missingRedirectLimit }, + encoding: "utf8", + }); + expect(redirectLimitResult.status).not.toBe(0); + expect(redirectLimitResult.stderr).toContain("_MAX_FETCH_REDIRECTS"); + + const missingValidationError = createPackageFixture(); + const missingValidationErrorPath = path.join( + missingValidationError, + "deepagents_code", + "tools.py", + ); + fs.writeFileSync( + missingValidationErrorPath, + fs + .readFileSync(missingValidationErrorPath, "utf8") + .replace("class _UrlValidationError", "class _RenamedUrlValidationError"), + "utf8", + ); + const validationErrorResult = spawnSync("python3", [patcher], { + env: { PATH: process.env.PATH, PYTHONPATH: missingValidationError }, + encoding: "utf8", + }); + expect(validationErrorResult.status).not.toBe(0); + expect(validationErrorResult.stderr).toContain("_UrlValidationError"); }); }); diff --git a/test/langchain-deepagents-code-fetch-proxy.test.ts b/test/langchain-deepagents-code-fetch-proxy.test.ts new file mode 100644 index 00000000000..e2b6ea3c2f0 --- /dev/null +++ b/test/langchain-deepagents-code-fetch-proxy.test.ts @@ -0,0 +1,809 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { addDarwinFcntlSealConstants } from "./helpers/darwin-fcntl-seal-fixture.ts"; +import { + makeStartScriptFixture, + runStartScriptProxyProbe, + TRUSTED_FETCH_PROXY_ENV_NAME, +} from "./helpers/langchain-deepagents-code-headless.ts"; +import { + cleanupPackageFixtures, + createPackageFixture, + patchFixture, +} from "./helpers/langchain-deepagents-code-patch-fixture.ts"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const agentDir = path.join(repoRoot, "agents", "langchain-deepagents-code"); + +afterEach(cleanupPackageFixtures); + +function readAgentFile(name: string): string { + return fs.readFileSync(path.join(agentDir, name), "utf8"); +} + +afterEach(cleanupPackageFixtures); + +describe("LangChain Deep Agents Code managed fetch proxy", () => { + it("persists the root-owned proxy as the explicit fetch_url delegation", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-fetch-proxy-")); + try { + const { envFile, scriptPath } = makeStartScriptFixture(tempDir, readAgentFile("start.sh")); + const { envFileText, output } = runStartScriptProxyProbe(scriptPath, envFile, {}); + const managedProxy = "http://10.200.0.1:3128"; + const outputLines = output.trimEnd().split("\n"); + + expect(outputLines).toContain(`RUNTIME_${TRUSTED_FETCH_PROXY_ENV_NAME}=${managedProxy}`); + expect(outputLines).toContain(`SOURCED_${TRUSTED_FETCH_PROXY_ENV_NAME}=${managedProxy}`); + expect(envFileText.trimEnd().split("\n")).toContain( + `export ${TRUSTED_FETCH_PROXY_ENV_NAME}=${managedProxy}`, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects consistently forged proxy env that differs from root-owned files", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-proxy-root-")); + try { + const hostFile = path.join(tempDir, "dcode-proxy-host"); + const portFile = path.join(tempDir, "dcode-proxy-port"); + const runtimeFile = path.join(tempDir, "managed-dcode-runtime.py"); + fs.writeFileSync(hostFile, "trusted-proxy.internal\n", { mode: 0o444 }); + fs.writeFileSync(portFile, "3129\n", { mode: 0o444 }); + fs.chmodSync(hostFile, 0o444); + fs.chmodSync(portFile, 0o444); + fs.writeFileSync( + runtimeFile, + addDarwinFcntlSealConstants(readAgentFile("managed-dcode-runtime.py")), + "utf8", + ); + const result = spawnSync( + "python3", + [ + "-c", + ` +import importlib.util +import os +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "nemoclaw_managed_proxy_test", + ${JSON.stringify(runtimeFile)}, +) +runtime = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runtime) +runtime._MANAGED_PROXY_HOST_FILE = Path(${JSON.stringify(hostFile)}) +runtime._MANAGED_PROXY_PORT_FILE = Path(${JSON.stringify(portFile)}) +runtime._MANAGED_FILE_OWNER_UID = os.getuid() + +for name in ( + "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", +): + os.environ[name] = "http://attacker.internal:4444" + +try: + runtime.managed_fetch_proxy_url() +except RuntimeError as exc: + assert str(exc) == "managed fetch URL proxy does not match root-owned proxy" + assert "attacker.internal" not in str(exc) +else: + raise AssertionError("consistently forged proxy environment was accepted") + +trusted = "http://trusted-proxy.internal:3129" +for name in ( + "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", +): + os.environ[name] = trusted +os.environ["NO_PROXY"] = "raw.githubusercontent.com" +assert runtime.managed_fetch_proxy_url() == trusted +print("root-owned-proxy-verification-ok") +`, + ], + { encoding: "utf8", env: { PATH: process.env.PATH } }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("root-owned-proxy-verification-ok"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("allows raw GitHub content only through GET and HEAD", () => { + const policy = YAML.parse(readAgentFile("policy-additions.yaml")) as { + network_policies?: Record> }>; + }; + const rawGitHub = policy.network_policies?.github?.endpoints?.find( + (endpoint) => endpoint.host === "raw.githubusercontent.com", + ); + + expect(rawGitHub).toEqual({ + host: "raw.githubusercontent.com", + port: 443, + protocol: "rest", + enforcement: "enforce", + rules: [ + { allow: { method: "GET", path: "/**" } }, + { allow: { method: "HEAD", path: "/**" } }, + ], + }); + }); + + it("pins the cloud E2E wiring for fetch_url success and denied-host paths", () => { + const check = fs.readFileSync( + path.join( + repoRoot, + "test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh", + ), + "utf8", + ); + + expect(check).toContain("fetch_url_probe_source"); + expect(check).toContain("from deepagents_code.tools import fetch_url"); + expect(check).toContain(TRUSTED_FETCH_PROXY_ENV_NAME); + expect(check).toContain("expect_fetch_reached"); + expect(check).toContain("FETCH_SUCCESS:2[0-9]{2}:[1-9][0-9]*"); + expect(check).toContain("https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/README.md"); + expect(check).toContain('expect_fetch_blocked "unapproved hosts" "https://example.com/"'); + expect(check).toContain( + 'expect_fetch_blocked "instance metadata" "https://169.254.169.254/latest/meta-data/"', + ); + expect(check).toContain('expect_fetch_blocked "sandbox loopback" "https://127.0.0.1/"'); + expect(check).not.toContain("'403 client error: forbidden'"); + }); + + it("pins concurrent CA bundle mutation fetches to original trust bytes or generic failure", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + const proxyUrl = "http://managed-proxy.internal:3128"; + const result = spawnSync( + "python3", + [ + "-c", + ` +import os +import sys +import types +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event, Lock, Thread + +PINNED_FETCH_COUNT = 4 +INVALID_FETCH_COUNT = 2 +pinned_ready = Event() +swap_complete = Event() +invalid_attempts_complete = Event() +release_pinned = Event() +state_lock = Lock() +pinned_transport_count = 0 +successful_ca_contents = [] +sessions = [] +mutation_errors = [] + +class Response: + status_code = 200 + headers = {} + + def raise_for_status(self): + return None + +class Session: + def __init__(self): + self.trust_env = True + self.closed = False + sessions.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.closed = True + + def get(self, _url, **kwargs): + global pinned_transport_count + with state_lock: + pinned_transport_count += 1 + if pinned_transport_count == PINNED_FETCH_COUNT: + pinned_ready.set() + assert release_pinned.wait(10), "timed out waiting for concurrent CA bundle mutation" + ca_contents = Path(kwargs["verify"]).read_text(encoding="utf-8") + with state_lock: + successful_ca_contents.append(ca_contents) + return Response() + +requests = types.ModuleType("requests") +requests.Session = Session +requests.exceptions = types.SimpleNamespace(TooManyRedirects=RuntimeError) +sys.modules["requests"] = requests + +from deepagents_code import _nemoclaw_managed, tools + +root = Path(${JSON.stringify(tempDir)}) +proxy_host_file = root / "managed-proxy-host" +proxy_port_file = root / "managed-proxy-port" +managed_ca_file = root / "managed-ca.pem" +attacker_ca_file = root / "attacker-ca.pem" +proxy_host_file.write_text("managed-proxy.internal\\n", encoding="utf-8") +proxy_port_file.write_text("3128\\n", encoding="utf-8") +managed_ca_file.write_text("trusted CA bundle\\n", encoding="utf-8") +attacker_ca_file.write_text("attacker CA bundle\\n", encoding="utf-8") +for trusted_file in (proxy_host_file, proxy_port_file, managed_ca_file, attacker_ca_file): + trusted_file.chmod(0o444) + +_nemoclaw_managed._MANAGED_PROXY_HOST_FILE = proxy_host_file +_nemoclaw_managed._MANAGED_PROXY_PORT_FILE = proxy_port_file +_nemoclaw_managed._MANAGED_FETCH_CA_BUNDLE_FILE = managed_ca_file +_nemoclaw_managed._MANAGED_FILE_OWNER_UID = os.getuid() + +for name in ( + "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", +): + os.environ[name] = ${JSON.stringify(proxyUrl)} + +def fetch_outcome(index): + try: + response = tools._fetch_with_redirects( + f"https://raw.githubusercontent.com/example/concurrent-ca-bundle-{index}", + timeout=8, + ) + except tools._UrlValidationError as exc: + return ("error", str(exc)) + return ("success", response.status_code) + +def atomically_symlink_swap_ca_bundle(): + try: + assert pinned_ready.wait(10), "managed fetches did not reach the pinned transport" + replacement = root / "managed-ca-symlink-replacement" + replacement.symlink_to(attacker_ca_file) + os.replace(replacement, managed_ca_file) + swap_complete.set() + assert invalid_attempts_complete.wait(10), "invalid fetches did not observe CA mutation" + except BaseException as exc: + mutation_errors.append(repr(exc)) + swap_complete.set() + finally: + release_pinned.set() + +mutator = Thread(target=atomically_symlink_swap_ca_bundle, name="ca-bundle-mutator") +mutator.start() +with ThreadPoolExecutor(max_workers=PINNED_FETCH_COUNT + INVALID_FETCH_COUNT) as executor: + try: + pinned_futures = [executor.submit(fetch_outcome, index) for index in range(PINNED_FETCH_COUNT)] + assert swap_complete.wait(10), "atomic CA bundle swap did not complete" + invalid_futures = [ + executor.submit(fetch_outcome, PINNED_FETCH_COUNT + index) + for index in range(INVALID_FETCH_COUNT) + ] + invalid_results = [future.result(timeout=10) for future in invalid_futures] + invalid_attempts_complete.set() + pinned_results = [future.result(timeout=10) for future in pinned_futures] + finally: + invalid_attempts_complete.set() + release_pinned.set() + +mutator.join(timeout=10) +assert not mutator.is_alive() +assert mutation_errors == [] +assert pinned_results == [("success", 200)] * PINNED_FETCH_COUNT +assert invalid_results == [ + ("error", "managed fetch CA bundle is invalid") +] * INVALID_FETCH_COUNT +assert successful_ca_contents == ["trusted CA bundle\\n"] * PINNED_FETCH_COUNT +assert all("attacker" not in contents for contents in successful_ca_contents) +assert managed_ca_file.is_symlink() +assert managed_ca_file.read_text(encoding="utf-8") == "attacker CA bundle\\n" +assert len(sessions) == PINNED_FETCH_COUNT +assert all(session.closed for session in sessions) +print("concurrent-ca-bundle-mutation-ok") +`, + ], + { + env: { + PATH: process.env.PATH, + PYTHONPATH: tempDir, + DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL: proxyUrl, + HTTP_PROXY: proxyUrl, + HTTPS_PROXY: proxyUrl, + http_proxy: proxyUrl, + https_proxy: proxyUrl, + }, + encoding: "utf8", + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("concurrent-ca-bundle-mutation-ok"); + }); + + it("keeps redirects and concurrent fetches behind the explicit proxy without direct DNS", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + const proxyUrl = "http://managed-proxy.internal:3128"; + const result = spawnSync( + "python3", + [ + "-c", + ` +import builtins +import os +import socket +import sys +import types +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Thread + +calls = [] +responses = [] +sessions = [] +ca_swap_thread = None + +class ProxyPolicyDenied(RuntimeError): + pass + +class Response: + def __init__(self, status_code=200, location=None, error=None): + self.status_code = status_code + self.headers = {} if location is None else {"Location": location} + self.error = error + + def raise_for_status(self): + if self.error is not None: + raise self.error + return None + +class Session: + def __init__(self): + self.trust_env = True + self.calls = [] + self.ca_contents = [] + self.closed = False + sessions.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.closed = True + + def get(self, url, **kwargs): + global ca_swap_thread + if ca_swap_thread is not None: + thread = ca_swap_thread + ca_swap_thread = None + thread.start() + thread.join() + assert not thread.is_alive() + self.ca_contents.append(Path(kwargs["verify"]).read_text(encoding="utf-8")) + call = (self.trust_env, url, kwargs) + self.calls.append(call) + calls.append(call) + return responses.pop(0) if responses else Response() + +requests = types.ModuleType("requests") +requests.Session = Session +requests.exceptions = types.SimpleNamespace(HTTPError=ProxyPolicyDenied, TooManyRedirects=RuntimeError) +sys.modules["requests"] = requests + +from deepagents_code import _nemoclaw_managed, tools +from deepagents_code._nemoclaw_managed import managed_fetch_proxy_url + +proxy_host_file = Path(${JSON.stringify(tempDir)}) / "managed-proxy-host" +proxy_port_file = Path(${JSON.stringify(tempDir)}) / "managed-proxy-port" +managed_ca_file = Path(${JSON.stringify(tempDir)}) / "managed-ca.pem" +attacker_ca_file = Path(${JSON.stringify(tempDir)}) / "attacker-ca.pem" +writable_ca_file = Path(${JSON.stringify(tempDir)}) / "writable-sensitive-ca.pem" +symlink_ca_file = Path(${JSON.stringify(tempDir)}) / "symlink-sensitive-ca.pem" +proxy_host_file.write_text("managed-proxy.internal\\n", encoding="utf-8") +proxy_port_file.write_text("3128\\n", encoding="utf-8") +managed_ca_file.write_text("test CA bundle\\n", encoding="utf-8") +attacker_ca_file.write_text("attacker CA bundle\\n", encoding="utf-8") +writable_ca_file.write_text("unsafe CA bundle\\n", encoding="utf-8") +writable_ca_file.chmod(0o666) +symlink_ca_file.symlink_to(managed_ca_file) +proxy_host_file.chmod(0o444) +proxy_port_file.chmod(0o444) +_nemoclaw_managed._MANAGED_PROXY_HOST_FILE = proxy_host_file +_nemoclaw_managed._MANAGED_PROXY_PORT_FILE = proxy_port_file +_nemoclaw_managed._MANAGED_FETCH_CA_BUNDLE_FILE = managed_ca_file +_nemoclaw_managed._MANAGED_FILE_OWNER_UID = os.getuid() +os.environ["REQUESTS_CA_BUNDLE"] = "relative/../hostile-requests-ca.pem" +os.environ["CURL_CA_BUNDLE"] = "/missing/hostile-curl-ca.pem" +os.environ["SSL_CERT_FILE"] = "/missing/hostile-ssl-ca.pem" + +def forbidden_direct_dns(*_args, **_kwargs): + raise AssertionError("managed fetch attempted direct DNS validation") + +tools._validate_url = forbidden_direct_dns +expected_proxies = {"http": ${JSON.stringify(proxyUrl)}, "https": ${JSON.stringify(proxyUrl)}} + +def assert_fd_ca_path(candidate): + assert candidate.startswith(("/proc/self/fd/", "/dev/fd/")) + +def assert_managed_hops(expected_urls): + assert [url for _, url, _ in calls] == expected_urls + assert all(trust_env is False for trust_env, _, _ in calls) + assert all(kwargs["proxies"] == expected_proxies for _, _, kwargs in calls) + assert all( + kwargs["verify"].startswith(("/proc/self/fd/", "/dev/fd/")) + for _, _, kwargs in calls + ) + assert sessions[-1].ca_contents == ["test CA bundle\\n"] * len(expected_urls) + +def expect_redirect_policy_denial(initial_url, redirect_url, label): + calls.clear() + denial = ProxyPolicyDenied(f"network policy denied {label}") + responses.extend([Response(302, redirect_url), Response(403, error=denial)]) + try: + tools._fetch_with_redirects(initial_url, timeout=8) + except ProxyPolicyDenied as exc: + assert exc is denial + assert str(exc) == f"network policy denied {label}" + else: + raise AssertionError(f"{label} redirect escaped proxy policy denial") + assert_managed_hops([initial_url, redirect_url]) + +response = tools._fetch_with_redirects("https://raw.githubusercontent.com/example/repo/main/README.md", timeout=8) +assert response.status_code == 200 +assert len(sessions) == 1 and sessions[0].closed +assert len(calls) == 1 +trust_env, called_url, call_kwargs = calls[0] +assert trust_env is False +assert called_url == "https://raw.githubusercontent.com/example/repo/main/README.md" +assert { + key: value for key, value in call_kwargs.items() if key != "verify" +} == { + "timeout": 8, + "headers": {"User-Agent": "Mozilla/5.0 (compatible; DeepAgents/1.0)"}, + "allow_redirects": False, + "proxies": {"http": ${JSON.stringify(proxyUrl)}, "https": ${JSON.stringify(proxyUrl)}}, +} +assert_fd_ca_path(call_kwargs["verify"]) +assert sessions[0].ca_contents == ["test CA bundle\\n"] + +calls.clear() +path_data_url = "https://raw.githubusercontent.com/example/path@segment:ordinary-data" +response = tools._fetch_with_redirects(path_data_url, timeout=8) +assert response.status_code == 200 +assert calls[0][1] == path_data_url + +calls.clear() +redirect_path_data_url = "https://raw.githubusercontent.com/example/@user:pass/source.py" +responses.extend([Response(302, redirect_path_data_url), Response(200)]) +response = tools._fetch_with_redirects( + "https://raw.githubusercontent.com/path-data-redirect", + timeout=8, +) +assert response.status_code == 200 +assert [url for _, url, _ in calls] == [ + "https://raw.githubusercontent.com/path-data-redirect", + redirect_path_data_url, +] + +calls.clear() +responses.extend([ + Response(302, "../main/README.md"), + Response(), +]) +response = tools._fetch_with_redirects( + "https://raw.githubusercontent.com/example/repo/start", + timeout=8, +) +assert response.status_code == 200 +assert_managed_hops([ + "https://raw.githubusercontent.com/example/repo/start", + "https://raw.githubusercontent.com/example/main/README.md", +]) + +# Cross-host redirects remain behind the same explicit proxy. The adapter does +# not locally authorize IMDS; it propagates the policy proxy's denial. The live +# egress check separately proves that OpenShell denies the IMDS destination. +metadata_url = "https://169.254.169.254/latest/meta-data/" +expect_redirect_policy_denial( + "https://raw.githubusercontent.com/example/redirect-to-imds", + metadata_url, + "cross-host metadata", +) + +# DNS and resolved-IP policy belong to OpenShell. A rebinding candidate must be +# passed by hostname through the explicit proxy without local DNS, and the +# proxy's denial must propagate rather than triggering a direct retry. +rebind_url = "https://rebind.internal/private" +original_getaddrinfo = socket.getaddrinfo +def forbidden_local_dns(*_args, **_kwargs): + raise AssertionError("managed redirect attempted local DNS") +socket.getaddrinfo = forbidden_local_dns +try: + expect_redirect_policy_denial( + "https://raw.githubusercontent.com/example/redirect-to-rebind", + rebind_url, + "DNS-rebinding hostname", + ) +finally: + socket.getaddrinfo = original_getaddrinfo + +calls.clear() +responses.append(Response(302)) +try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/missing-location", timeout=8) +except tools._UrlValidationError as exc: + assert "missing a Location header" in str(exc) +else: + raise AssertionError("redirect without Location escaped validation") + +calls.clear() +responses.append(Response(302, "http://user:redirect-secret@proxy.internal:3128/private")) +try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/credential-redirect", timeout=8) +except tools._UrlValidationError as exc: + assert str(exc) == "URL credentials are not allowed" + assert "redirect-secret" not in str(exc) +else: + raise AssertionError("credentialed redirect escaped validation") +assert len(calls) == 1 + +sensitive_idna_label = "sk-EXAMPLE-DO-NOT-REFLECT" +invalid_idna_url = f"https://{sensitive_idna_label}{chr(0xD800)}.invalid/private" +calls.clear() +try: + tools._fetch_with_redirects(invalid_idna_url, timeout=8) +except tools._UrlValidationError as exc: + assert str(exc) == "URL hostname is not valid IDNA" + assert sensitive_idna_label not in str(exc) + assert "Unicode" not in str(exc) + assert exc.__cause__ is None + assert exc.__suppress_context__ is True +else: + raise AssertionError("invalid initial IDNA hostname escaped validation") +assert calls == [] + +calls.clear() +responses.append(Response(302, invalid_idna_url)) +try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/idna-redirect", timeout=8) +except tools._UrlValidationError as exc: + assert str(exc) == "URL hostname is not valid IDNA" + assert sensitive_idna_label not in str(exc) + assert "Unicode" not in str(exc) + assert exc.__cause__ is None + assert exc.__suppress_context__ is True +else: + raise AssertionError("invalid redirect IDNA hostname escaped validation") +assert len(calls) == 1 + +calls.clear() +responses.append(Response(302, "https://raw.githubusercontent.com/next")) +tools._MAX_FETCH_REDIRECTS = 0 +try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/start", timeout=8) +except RuntimeError as exc: + assert "Exceeded 0 redirects" in str(exc) +else: + raise AssertionError("managed fetch ignored the reviewed upstream redirect cap") +assert len(calls) == 1 + +tools._MAX_FETCH_REDIRECTS = 5 +for malformed in ("https://[broken", "https://example.com:not-a-port"): + try: + tools._fetch_with_redirects(malformed, timeout=8) + except tools._UrlValidationError as exc: + assert str(exc) == "URL is malformed" + else: + raise AssertionError(f"malformed URL escaped validation: {malformed}") + +os.environ["HTTP_PROXY"] = "http://attacker.internal:4444" +try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/example", timeout=8) +except tools._UrlValidationError as exc: + assert str(exc) == "managed fetch URL proxy does not match runtime proxy" + assert "attacker.internal" not in str(exc) +else: + raise AssertionError("proxy-integrity error escaped fetch_url validation") + +for invalid_proxy in ( + "http://user:proxy-secret@proxy.internal:3128", + "http://proxy.internal", + "http://proxy.internal:0", + "http://proxy.internal:70000", + "http://proxy.internal:3128/unexpected", + "http://proxy.internal:3128?route=unsafe", + "http://proxy.internal:3128#fragment", + " http://proxy.internal:3128", + "http://proxy.internal:3128\\n", +): + os.environ["DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL"] = invalid_proxy + for proxy_name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"): + os.environ[proxy_name] = invalid_proxy + try: + managed_fetch_proxy_url() + except RuntimeError as exc: + assert str(exc) == "managed fetch URL proxy is invalid" + assert "proxy-secret" not in str(exc) + else: + raise AssertionError(f"invalid managed proxy was accepted: {invalid_proxy!r}") + +for proxy_name in ( + "DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", +): + os.environ[proxy_name] = ${JSON.stringify(proxyUrl)} + +original_import = builtins.__import__ +def block_requests_import(name, *args, **kwargs): + if name == "requests": + raise ImportError("private import detail from /sensitive/runtime/path") + return original_import(name, *args, **kwargs) + +builtins.__import__ = block_requests_import +try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/example", timeout=8) +except tools._UrlValidationError as exc: + assert str(exc) == "managed fetch transport dependency is unavailable" + assert "ImportError" not in str(exc) + assert "sensitive" not in str(exc) + assert exc.__cause__ is None + assert exc.__suppress_context__ is True +else: + raise AssertionError("requests ImportError escaped the structured validation path") +finally: + builtins.__import__ = original_import + +# Atomically replace the validated pathname after the managed helper opens it, +# but before the transport consumes the CA input. The fd-backed verify path +# must remain pinned to the original root-owned trust bytes. +def replace_managed_ca_path(): + replacement = managed_ca_file.with_name("managed-ca-replacement") + if replacement.exists() or replacement.is_symlink(): + replacement.unlink() + replacement.symlink_to(attacker_ca_file) + os.replace(replacement, managed_ca_file) + +calls.clear() +ca_swap_thread = Thread(target=replace_managed_ca_path) +response = tools._fetch_with_redirects( + "https://raw.githubusercontent.com/example/ca-swap", + timeout=8, +) +assert response.status_code == 200 +assert managed_ca_file.is_symlink() +assert managed_ca_file.read_text(encoding="utf-8") == "attacker CA bundle\\n" +assert sessions[-1].ca_contents == ["test CA bundle\\n"] +assert_fd_ca_path(calls[0][2]["verify"]) +managed_ca_file.unlink() +managed_ca_file.write_text("test CA bundle\\n", encoding="utf-8") + +for invalid_ca_bundle, expected_error in ( + ( + Path(${JSON.stringify(tempDir)}) / "missing-sensitive-ca.pem", + "managed fetch CA bundle is unavailable", + ), + (symlink_ca_file, "managed fetch CA bundle is invalid"), + (writable_ca_file, "managed fetch CA bundle is invalid"), +): + _nemoclaw_managed._MANAGED_FETCH_CA_BUNDLE_FILE = invalid_ca_bundle + try: + tools._fetch_with_redirects("https://raw.githubusercontent.com/example", timeout=8) + except tools._UrlValidationError as exc: + assert str(exc) == expected_error + assert "sensitive" not in str(exc) + assert exc.__cause__ is None + else: + raise AssertionError(f"invalid CA bundle was accepted: {invalid_ca_bundle!r}") + +_nemoclaw_managed._MANAGED_FETCH_CA_BUNDLE_FILE = managed_ca_file +_nemoclaw_managed._MANAGED_FILE_OWNER_UID = os.getuid() + 1 +try: + _nemoclaw_managed._managed_fetch_ca_bundle() +except RuntimeError as exc: + assert str(exc) == "managed fetch CA bundle is invalid" +else: + raise AssertionError("wrong-owner CA bundle was accepted") +_nemoclaw_managed._MANAGED_FILE_OWNER_UID = os.getuid() + +# The proxy and CA are immutable process-wide image inputs. Per-call isolation +# therefore means a fresh Session and explicit proxy mapping for each fetch, +# including concurrent calls, rather than unsupported mutable configurations. +calls.clear() +session_start = len(sessions) +concurrent_urls = [ + f"https://raw.githubusercontent.com/example/concurrent-{index}" + for index in range(4) +] +with ThreadPoolExecutor(max_workers=len(concurrent_urls)) as executor: + concurrent_responses = list(executor.map( + lambda candidate: tools._fetch_with_redirects(candidate, timeout=8), + concurrent_urls, + )) +concurrent_sessions = sessions[session_start:] +assert all(response.status_code == 200 for response in concurrent_responses) +assert len(concurrent_sessions) == len(concurrent_urls) +assert all(session.trust_env is False for session in concurrent_sessions) +assert all(len(session.calls) == 1 for session in concurrent_sessions) +assert {session.calls[0][1] for session in concurrent_sessions} == set(concurrent_urls) +assert all( + session.calls[0][2]["proxies"] == expected_proxies + for session in concurrent_sessions +) +assert all( + session.calls[0][2]["verify"].startswith(("/proc/self/fd/", "/dev/fd/")) + for session in concurrent_sessions +) +assert all( + session.ca_contents == ["test CA bundle\\n"] + for session in concurrent_sessions +) +assert len({id(session.calls[0][2]["proxies"]) for session in concurrent_sessions}) == len( + concurrent_sessions +) +assert all(session.closed for session in concurrent_sessions) +assert sessions and all(session.closed for session in sessions) +assert len({id(session) for session in sessions}) == len(sessions) +print("managed-fetch-proxy-ok") +`, + ], + { + env: { + PATH: process.env.PATH, + PYTHONPATH: tempDir, + DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL: proxyUrl, + HTTP_PROXY: proxyUrl, + HTTPS_PROXY: proxyUrl, + http_proxy: proxyUrl, + https_proxy: proxyUrl, + NO_PROXY: "raw.githubusercontent.com", + no_proxy: "raw.githubusercontent.com", + }, + encoding: "utf8", + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("managed-fetch-proxy-ok"); + }); + + it("preserves unmanaged fallback when proxy env is absent", () => { + const tempDir = createPackageFixture(); + patchFixture(tempDir); + + const withoutDelegation = spawnSync( + "python3", + [ + "-c", + [ + "from deepagents_code import tools", + 'result = tools._fetch_with_redirects("https://example.com", timeout=3)', + 'assert result == {"transport": "direct", "url": "https://example.com", "timeout": 3}', + ].join("; "), + ], + { + env: { PATH: process.env.PATH, PYTHONPATH: tempDir }, + encoding: "utf8", + }, + ); + + expect(withoutDelegation.status, withoutDelegation.stderr).toBe(0); + }); +}); diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index fa4d0ac7c92..28e57f8cb19 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -375,7 +375,6 @@ describe("LangChain Deep Agents Code image contracts", () => { it("keeps optional service egress out of the default policy and requires Landlock", () => { const policy = readAgentFile("policy-additions.yaml"); - expect(policy).not.toContain("api.tavily.com"); expect(policy).not.toContain("api.smith.langchain.com"); expect(policy).not.toContain("supabase.co"); diff --git a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts index ae7809ff49f..3a6ae89e171 100644 --- a/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts +++ b/test/langchain-deepagents-code-progressive-tool-disclosure.test.ts @@ -90,6 +90,15 @@ def _load_dotenv(*, start_path=None, refresh_loaded=False): return False def _parse_interpreter_ptc(raw): return raw def _preview_dotenv_environ(*, start_path=None): return {} def _tracing_enabled(): return False +`, + "tools.py": `from __future__ import annotations + +_MAX_FETCH_REDIRECTS = 5 + +class _UrlValidationError(ValueError): pass + +def _fetch_with_redirects(url, *, timeout): + return url, timeout `, "model_config.py": `from __future__ import annotations diff --git a/test/langchain-deepagents-code-proxy-launcher.test.ts b/test/langchain-deepagents-code-proxy-launcher.test.ts index e44db3a5186..099739f1ca9 100644 --- a/test/langchain-deepagents-code-proxy-launcher.test.ts +++ b/test/langchain-deepagents-code-proxy-launcher.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { isValidProxyHost, isValidProxyPort } from "../src/lib/onboard/dockerfile-patch.ts"; +import { TRUSTED_FETCH_PROXY_ENV_NAME } from "./helpers/langchain-deepagents-code-headless.ts"; const agentDir = path.join(process.cwd(), "agents", "langchain-deepagents-code"); const headlessCheckPath = path.join( @@ -71,7 +72,7 @@ function makeLauncherProxyProbeFixture( const probePath = path.join(tempDir, "managed-dcode-probe.sh"); const probe = [ "#!/bin/bash -p", - "for name in HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy ALL_PROXY all_proxy OPENAI_PROXY NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT NEMOCLAW_OBSERVABILITY; do", + `for name in HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy ALL_PROXY all_proxy OPENAI_PROXY ${TRUSTED_FETCH_PROXY_ENV_NAME} NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT NEMOCLAW_OBSERVABILITY; do`, ' printf \'LAUNCHER_%s=%s\\n\' "$name" "${!name-__unset__}"', "done", "", @@ -226,6 +227,7 @@ describe("Deep Agents Code direct-exec proxy launcher", () => { for (const name of PROXY_URL_ENV_NAMES) { expect(lines).toContain(`LAUNCHER_${name}=${managedProxy}`); } + expect(lines).toContain(`LAUNCHER_${TRUSTED_FETCH_PROXY_ENV_NAME}=${managedProxy}`); for (const name of NO_PROXY_ENV_NAMES) { expect(lines).toContain(`LAUNCHER_${name}=${managedNoProxy}`); } @@ -364,6 +366,7 @@ describe("Deep Agents Code direct-exec proxy launcher", () => { 'export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"', ); expect(launcher).toContain('export HTTPS_PROXY="$_PROXY_URL"'); + expect(launcher).toContain('export DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL="$_PROXY_URL"'); expect(launcher).toContain('export no_proxy="$_NO_PROXY_VAL"'); expect(launcher).toContain("unset ALL_PROXY all_proxy OPENAI_PROXY"); }); @@ -379,6 +382,7 @@ describe("Deep Agents Code direct-exec proxy launcher", () => { ALL_PROXY: "socks5://all-user:all-password@all-proxy.example:1080", all_proxy: "socks5://lower-all-user:lower-all-password@lower-all-proxy.example:1080", OPENAI_PROXY: "http://openai-user:openai-password@attacker.example:8080", + DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL: "http://attacker-proxy.internal:4444", NEMOCLAW_PROXY_HOST: "attacker-proxy.internal", NEMOCLAW_PROXY_PORT: "4444", }; @@ -407,6 +411,12 @@ describe("Deep Agents Code direct-exec proxy launcher", () => { "START_PROXY=http://trusted-proxy.internal:3129|localhost,127.0.0.1,::1,trusted-proxy.internal|__unset__|__unset__|__unset__|__unset__", ); expect(envFileText).toContain("export HTTPS_PROXY=http://trusted-proxy.internal:3129"); + expect(envFileText).toContain( + "export DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL=http://trusted-proxy.internal:3129", + ); + expect(launcherResult.stdout).toContain( + "LAUNCHER_DEEPAGENTS_CODE_FETCH_URL_TRUSTED_PROXY_URL=http://trusted-proxy.internal:3129", + ); expect(envFileText).toContain( "export NO_PROXY=localhost\\,127.0.0.1\\,::1\\,trusted-proxy.internal", );