From f57936668372db21baf21c8d5d51356b4400e1ff Mon Sep 17 00:00:00 2001 From: MytelligentPRV Date: Sat, 27 Jun 2026 09:17:51 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(code-editing):=20Step=201=20=E2=80=94?= =?UTF-8?q?=20dependency=20pipeline=20(install-RCE=20control)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security core's install side. A user's requirements are validated against a curated pinned+hashed allowlist, installed wheels-only in an isolated mirror-only- egress build sandbox, and snapshotted into a per-version image. Pure-additive/inert. services/agent_deps.py: • GATEKEEPER validate_requirements / resolve_install_set — fail-closed: only exact name==version from the allowlist passes; rejects unpinned, ranges, markers, unknown pkg, disallowed version, duplicates, -e/-r/git+/url lines, >20 deps. Base closure (wayforth-sdk + httpx + httpx's pinned deps) baked into every image (the §3 prerequisite for Step 2's gateway-only run egress). • pip_install_command — the install-RCE control: --only-binary=:all: (no setup.py execution) --require-hashes --no-deps --index-url . • build_egress / run_egress — the TWO-allowlist separation: build = deny 0.0.0.0/0 + allow [mirror] only (NOT gateway); run = allow [gateway] only (NOT mirror). • build_agent_image — validate → mirror-only build sandbox → wheels-only hashed install (capped 180s / 500MB) → create_snapshot → per-version image_ref. Injectable sandbox factory; default uses the e2b snapshot mechanism proven live in §4. • caps: MAX_DIRECT_DEPS=20, BUILD_TIMEOUT_S=180, MAX_IMAGE_DELTA_MB=500. services/agent_deps_lock.json — seed allowlist with REAL sha256 hashes fetched in the linux container (so wheels match E2B's platform): base closure + requests/bs4/urllib3/ charset-normalizer/python-dateutil/six. Expanded later via a curation queue. Tests (28): gatekeeper accept + every reject path; base-closure + conflict; lock-file hashes; wheels-only/hashed/mirror command; two-allowlist separation; build orchestration via fake sandbox (egress=mirror-only, files+lock written, install ran, snapshot named per-version, killed); invalid-reqs rejected BEFORE any sandbox spun; install-failure raises+kills; path-traversal rejected. Full suite green (577 passed). NOT in this PR (tracked): the private mirror server (infra; DEPS_MIRROR_URL config); the live §6 ship-gate tests (malicious-package canary etc.) before user-visible; the E2B Firecracker-token confirmation (launch gate). Step 2 (run-egress lock) builds on the baked base deps here. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/pytest.ini | 1 + apps/api/services/agent_deps.py | 258 +++++++++++++++++++++++++ apps/api/services/agent_deps_lock.json | 67 +++++++ apps/api/tests/test_agent_deps.py | 221 +++++++++++++++++++++ 4 files changed, 547 insertions(+) create mode 100644 apps/api/services/agent_deps.py create mode 100644 apps/api/services/agent_deps_lock.json create mode 100644 apps/api/tests/test_agent_deps.py diff --git a/apps/api/pytest.ini b/apps/api/pytest.ini index 534d162..998dfcc 100644 --- a/apps/api/pytest.ini +++ b/apps/api/pytest.ini @@ -29,5 +29,6 @@ python_files = test_suite_v060.py test_suite_v062.py test_suite_v0610.py test_se test_cloud_params_upload.py test_params_eval.py test_templates_params.py + test_agent_deps.py markers = no_api_key: test does not require WAYFORTH_TEST_API_KEY (e.g. probes unauthenticated paths) diff --git a/apps/api/services/agent_deps.py b/apps/api/services/agent_deps.py new file mode 100644 index 0000000..2899884 --- /dev/null +++ b/apps/api/services/agent_deps.py @@ -0,0 +1,258 @@ +"""services/agent_deps.py — agent code-editing v1, Step 1: dependency pipeline. + +THE install-time-RCE control. A user's requirements are validated against a curated, +pinned, hashed ALLOWLIST (agent_deps_lock.json — the only packages the private mirror +serves), then installed WHEELS-ONLY (no setup.py execution) in an isolated build +sandbox whose egress is restricted to the mirror only, then snapshotted into a +per-version image that run sandboxes boot from. Nothing here trusts user input. + +Defense in depth (each layer independently reduces blast radius): + • allowlist + private mirror → only vetted packages can install at all + • pinned (==) + --require-hashes → exact, reproducible artifacts + • --only-binary=:all: → no setup.py / build hooks run at install + • --no-deps + locked closure → no surprise transitive pulls + • --index-url= → resolver can't reach public PyPI + • build-sandbox egress = mirror-only → real network isolation at build (proven) + • (Step 2) run-sandbox egress = gateway → no exfiltration at run + +This module is pure logic + an injectable build orchestration; importing it is inert. +""" +from __future__ import annotations + +import json +import os +import re +import shlex + +logger_name = "wayforth" + +# ── caps (anti-DoS / anti-bloat) ──────────────────────────────────────────────── +MAX_DIRECT_DEPS = 20 +BUILD_TIMEOUT_S = 180 +MAX_IMAGE_DELTA_MB = 500 + +# Base closure baked into EVERY agent image. Gateway-only run egress (Step 2) makes +# run-time pip impossible, so the SDK + http client (and httpx's pinned closure) live +# in the image. All present in the lockfile. +BASE_DEPS = [ + ("wayforth-sdk", "0.9.0"), + ("httpx", "0.28.1"), + ("anyio", "4.14.1"), + ("sniffio", "1.3.1"), + ("h11", "0.16.0"), + ("certifi", "2026.6.17"), + ("idna", "3.18"), +] + +_REQS_PATH = "/home/user/requirements.lock" +_LOCK_PATH = os.path.join(os.path.dirname(__file__), "agent_deps_lock.json") +_LOCK_CACHE: dict | None = None + +# A requirement must be EXACTLY name==version — nothing else (no ranges, markers, +# extras, urls, options, editable installs). +_PIN_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9][A-Za-z0-9.+!_-]*)$") +_SAFE_PATH_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_./-]*$") + + +class DepsError(Exception): + """Validation/build failure. `.errors` is a list of {field, code, message}.""" + + def __init__(self, errors: list): + self.errors = errors + super().__init__("; ".join(e.get("message", "") for e in errors) or "deps error") + + +def _norm(name: str) -> str: + """PEP 503 normalization for package names.""" + return re.sub(r"[-_.]+", "-", name.strip().lower()) + + +def load_lockfile() -> dict: + """The curated allowlist: {normalized_name: {version: [sha256 hashes]}}.""" + global _LOCK_CACHE + if _LOCK_CACHE is None: + with open(_LOCK_PATH) as f: + raw = json.load(f) + _LOCK_CACHE = {_norm(k): v for k, v in raw.items()} + return _LOCK_CACHE + + +# ── the gatekeeper ────────────────────────────────────────────────────────────── + +def validate_requirements(text: str, lockfile: dict | None = None): + """Validate a requirements.txt body against the allowlist. Returns (pins, errors). + + Each pin is (name, version, [hashes]). A non-empty `errors` list ⇒ reject (422). + Fail-closed: anything not exactly `name==version` from the allowlist is rejected. + """ + lock = lockfile if lockfile is not None else load_lockfile() + pins: list = [] + errors: list = [] + seen: set = set() + + lines = [ln.strip() for ln in (text or "").splitlines()] + lines = [ln for ln in lines if ln and not ln.startswith("#")] + + if len(lines) > MAX_DIRECT_DEPS: + errors.append({"field": None, "code": "too_many", + "message": f"too many dependencies ({len(lines)} > {MAX_DIRECT_DEPS})"}) + + for ln in lines: + if ln.startswith("-") or "://" in ln or " @ " in ln: + errors.append({"field": ln, "code": "unsupported", + "message": f"unsupported requirement line: {ln!r} " + "(only 'name==version' is allowed)"}) + continue + m = _PIN_RE.match(ln) + if not m: + errors.append({"field": ln, "code": "unpinned", + "message": f"requirement must be pinned 'name==version': {ln!r}"}) + continue + name, version = _norm(m.group(1)), m.group(2) + if name in seen: + errors.append({"field": name, "code": "duplicate", + "message": f"duplicate requirement '{name}'"}) + continue + seen.add(name) + if name not in lock: + errors.append({"field": name, "code": "not_allowed", + "message": f"'{name}' is not in the allowlist (request it for review)"}) + continue + if version not in lock[name]: + errors.append({"field": name, "code": "version_not_allowed", + "message": f"'{name}=={version}' is not an allowed version " + f"(allowed: {sorted(lock[name])})"}) + continue + pins.append((name, version, list(lock[name][version]))) + + return pins, errors + + +def resolve_install_set(requirements_text: str, lockfile: dict | None = None): + """Base closure + validated user pins, deduped. Returns (install_set, errors). + + A user pin that names a base dep at a different version is a conflict (base wins). + """ + lock = lockfile if lockfile is not None else load_lockfile() + user_pins, errors = validate_requirements(requirements_text, lock) + + base = {} + for name, version in BASE_DEPS: + n = _norm(name) + base[n] = (n, version, list(lock[n][version])) + + out = dict(base) + for name, version, hashes in user_pins: + if name in base and base[name][1] != version: + errors.append({"field": name, "code": "base_dep_conflict", + "message": f"'{name}' is a base dependency pinned to " + f"{base[name][1]}; cannot override with {version}"}) + continue + out[name] = (name, version, hashes) + + if errors: + return [], errors + return list(out.values()), [] + + +# ── install command + egress (two-allowlist) ──────────────────────────────────── + +def build_requirements_lock(install_set) -> str: + """`name==version --hash=sha256:… ` lines for --require-hashes.""" + lines = [] + for name, version, hashes in install_set: + hs = " ".join(f"--hash={h}" for h in hashes) + lines.append(f"{name}=={version} {hs}") + return "\n".join(lines) + "\n" + + +def pip_install_command(mirror_url: str, reqs_path: str = _REQS_PATH) -> str: + """The wheels-only, hashed, mirror-pinned, no-deps install — the install-RCE control.""" + return ( + "pip install --only-binary=:all: --require-hashes --no-deps --no-input -q " + f"--index-url {shlex.quote(mirror_url)} -r {shlex.quote(reqs_path)} " + "--break-system-packages" + ) + + +def build_egress(mirror_host: str) -> dict: + """BUILD sandbox egress: deny all, allow ONLY the mirror (never the gateway).""" + return {"deny_out": ["0.0.0.0/0"], "allow_out": [mirror_host]} + + +def run_egress(gateway_host: str = "gateway.wayforth.io") -> dict: + """RUN sandbox egress: deny all, allow ONLY the gateway (never the mirror).""" + return {"deny_out": ["0.0.0.0/0"], "allow_out": [gateway_host]} + + +# ── build orchestration (injectable sandbox factory for tests) ────────────────── + +def _safe_rel_path(path: str) -> str: + p = (path or "").strip().lstrip("/") + if not p or ".." in p.split("/") or not _SAFE_PATH_RE.match(p): + raise DepsError([{"field": path, "code": "bad_path", + "message": f"unsafe file path: {path!r}"}]) + return p + + +def _default_sandbox_factory(egress: dict, timeout_s: int): + from e2b import Sandbox, SandboxNetworkOpts + return Sandbox.create( + timeout=timeout_s, + network=SandboxNetworkOpts(deny_out=egress["deny_out"], allow_out=egress["allow_out"]), + ) + + +def build_agent_image( + agent_id: str, + version: int, + files: dict, + requirements_text: str, + *, + mirror_url: str, + mirror_host: str, + sandbox_factory=_default_sandbox_factory, +) -> str: + """Validate → isolated mirror-only build → wheels-only hashed install → snapshot. + + Returns the per-version image ref (snapshot id). Raises DepsError on validation or + install failure. The build sandbox holds NO Wayforth secrets and can reach only the + mirror; it is always killed. + """ + install_set, errors = resolve_install_set(requirements_text) + if errors: + raise DepsError(errors) + + reqs_lock = build_requirements_lock(install_set) + cmd = pip_install_command(mirror_url) + + sbx = sandbox_factory(build_egress(mirror_host), BUILD_TIMEOUT_S) + try: + for path, content in (files or {}).items(): + sbx.files.write(f"/home/user/{_safe_rel_path(path)}", content) + sbx.files.write(_REQS_PATH, reqs_lock) + + res = sbx.commands.run(cmd, timeout=float(BUILD_TIMEOUT_S)) + if getattr(res, "exit_code", 1) != 0: + raise DepsError([{"field": None, "code": "install_failed", + "message": (getattr(res, "stderr", "") or "")[:500]}]) + + # Image-size ceiling (approximate; site-packages growth). + du = sbx.commands.run( + "du -sm /usr/lib/python3*/site-packages /home/user 2>/dev/null " + "| awk '{s+=$1} END{print s+0}'") + if int((getattr(du, "stdout", "0") or "0").strip() or 0) > MAX_IMAGE_DELTA_MB: + raise DepsError([{"field": None, "code": "image_too_large", + "message": f"image exceeds {MAX_IMAGE_DELTA_MB} MB cap"}]) + + snap = sbx.create_snapshot(name=f"agent-{agent_id}-v{version}") + image_ref = getattr(snap, "snapshot_id", None) or getattr(snap, "template_id", None) + if not image_ref: + raise DepsError([{"field": None, "code": "snapshot_failed", + "message": "no image ref returned"}]) + return image_ref + finally: + try: + sbx.kill() + except Exception: + pass diff --git a/apps/api/services/agent_deps_lock.json b/apps/api/services/agent_deps_lock.json new file mode 100644 index 0000000..f1238da --- /dev/null +++ b/apps/api/services/agent_deps_lock.json @@ -0,0 +1,67 @@ +{ + "anyio": { + "4.14.1": [ + "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72" + ] + }, + "beautifulsoup4": { + "4.15.0": [ + "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9" + ] + }, + "certifi": { + "2026.6.17": [ + "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" + ] + }, + "charset-normalizer": { + "3.4.7": [ + "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116" + ] + }, + "h11": { + "0.16.0": [ + "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" + ] + }, + "httpx": { + "0.28.1": [ + "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" + ] + }, + "idna": { + "3.18": [ + "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" + ] + }, + "python-dateutil": { + "2.9.0.post0": [ + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" + ] + }, + "requests": { + "2.34.2": [ + "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" + ] + }, + "six": { + "1.17.0": [ + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" + ] + }, + "sniffio": { + "1.3.1": [ + "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" + ] + }, + "urllib3": { + "2.7.0": [ + "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" + ] + }, + "wayforth-sdk": { + "0.9.0": [ + "sha256:234d07cc3646fc7887873a991e626ae2432a371724a87632abf4081ac15bd95a" + ] + } +} diff --git a/apps/api/tests/test_agent_deps.py b/apps/api/tests/test_agent_deps.py new file mode 100644 index 0000000..fbd8cfe --- /dev/null +++ b/apps/api/tests/test_agent_deps.py @@ -0,0 +1,221 @@ +"""test_agent_deps.py — code-editing v1, Step 1 (dependency pipeline). + +Heavy on the GATEKEEPER (the install-RCE control): only allowlisted, pinned, hashed +packages pass; everything else is rejected fail-closed. Plus the wheels-only/hashed/ +mirror install command, the two-allowlist egress, and the build orchestration. +""" +from __future__ import annotations + +import pytest + +from services import agent_deps as ad +from services.agent_deps import ( + DepsError, build_agent_image, build_egress, build_requirements_lock, + pip_install_command, resolve_install_set, run_egress, validate_requirements, +) + +LOCK = { + "httpx": {"0.28.1": ["sha256:aaa"]}, + "requests": {"2.34.2": ["sha256:bbb"]}, + "idna": {"3.18": ["sha256:ccc"]}, +} + + +def _codes(errors): + return {(e["code"]) for e in errors} + + +# ── gatekeeper: accept ────────────────────────────────────────────────────────── + +def test_valid_pin_accepted(): + pins, errors = validate_requirements("requests==2.34.2", LOCK) + assert errors == [] + assert pins == [("requests", "2.34.2", ["sha256:bbb"])] + + +def test_comments_and_blanks_ignored(): + pins, errors = validate_requirements("# deps\n\nhttpx==0.28.1\n", LOCK) + assert errors == [] and pins[0][0] == "httpx" + + +def test_name_normalization(): + pins, errors = validate_requirements("Requests==2.34.2", LOCK) + assert errors == [] and pins[0][0] == "requests" + + +# ── gatekeeper: reject (the product) ──────────────────────────────────────────── + +def test_unpinned_rejected(): + assert "unpinned" in _codes(validate_requirements("httpx", LOCK)[1]) + + +@pytest.mark.parametrize("line", ["httpx>=0.28", "httpx~=0.28", "httpx==0.28,<1", "httpx==0.28.1 ; python_version>'3'"]) +def test_ranges_and_markers_rejected(line): + assert validate_requirements(line, LOCK)[1] # any error + + +def test_unknown_package_rejected(): + assert "not_allowed" in _codes(validate_requirements("evil-pkg==1.0", LOCK)[1]) + + +def test_disallowed_version_rejected(): + assert "version_not_allowed" in _codes(validate_requirements("httpx==9.9.9", LOCK)[1]) + + +def test_duplicate_rejected(): + assert "duplicate" in _codes(validate_requirements("httpx==0.28.1\nhttpx==0.28.1", LOCK)[1]) + + +@pytest.mark.parametrize("line", ["-e .", "-r other.txt", "git+https://x/y.git", + "https://files/x.whl", "pkg @ https://x/y.whl"]) +def test_unsupported_lines_rejected(line): + assert "unsupported" in _codes(validate_requirements(line, LOCK)[1]) + + +def test_too_many_deps_rejected(): + body = "\n".join(f"p{i}==1.0" for i in range(21)) + assert "too_many" in _codes(validate_requirements(body, LOCK)[1]) + + +# ── resolve_install_set: base closure + user, conflicts ───────────────────────── + +def test_base_deps_always_included(): + install_set, errors = resolve_install_set("requests==2.34.2") # real lockfile + assert errors == [] + names = {n for n, _, _ in install_set} + assert {"wayforth-sdk", "httpx", "anyio"} <= names # base closure baked + assert "requests" in names # user dep added + + +def test_base_dep_version_conflict_rejected(): + # httpx is a base dep pinned to 0.28.1; overriding it is a conflict + install_set, errors = resolve_install_set("httpx==0.27.0") + assert "version_not_allowed" in _codes(errors) or "base_dep_conflict" in _codes(errors) + + +def test_user_errors_propagate(): + install_set, errors = resolve_install_set("evil==1.0") + assert install_set == [] and "not_allowed" in _codes(errors) + + +# ── install command + lock file ───────────────────────────────────────────────── + +def test_requirements_lock_has_hashes(): + body = build_requirements_lock([("requests", "2.34.2", ["sha256:bbb"])]) + assert "requests==2.34.2 --hash=sha256:bbb" in body + + +def test_pip_command_is_wheels_only_hashed_mirror_pinned(): + cmd = pip_install_command("https://mirror.internal/simple") + assert "--only-binary=:all:" in cmd + assert "--require-hashes" in cmd + assert "--no-deps" in cmd + assert "--index-url https://mirror.internal/simple" in cmd + + +# ── two-allowlist egress separation ───────────────────────────────────────────── + +def test_egress_two_allowlists_are_separate(): + b = build_egress("mirror.internal") + r = run_egress("gateway.wayforth.io") + assert b == {"deny_out": ["0.0.0.0/0"], "allow_out": ["mirror.internal"]} + assert r == {"deny_out": ["0.0.0.0/0"], "allow_out": ["gateway.wayforth.io"]} + # build can't reach gateway; run can't reach mirror + assert "gateway.wayforth.io" not in b["allow_out"] + assert "mirror.internal" not in r["allow_out"] + + +# ── build orchestration (fake sandbox) ────────────────────────────────────────── + +class _Res: + def __init__(self, exit_code=0, stdout="", stderr=""): + self.exit_code, self.stdout, self.stderr = exit_code, stdout, stderr + + +class _Snap: + snapshot_id = "snap-abc" + + +class _FakeSandbox: + def __init__(self, install_exit=0): + self.written, self.ran, self.killed = {}, [], False + self.snapshot_name = None + self._install_exit = install_exit + self.files = self + self.commands = self + + def write(self, path, content): + self.written[path] = content + + def run(self, cmd, timeout=None): + self.ran.append(cmd) + if "du -sm" in cmd: + return _Res(0, "15") # 15 MB, under cap + return _Res(self._install_exit, "ok", "boom" if self._install_exit else "") + + def create_snapshot(self, name=None): + self.snapshot_name = name + return _Snap() + + def kill(self): + self.killed = True + + +def _factory_for(sbx, captured): + def factory(egress, timeout_s): + captured["egress"] = egress + captured["timeout"] = timeout_s + return sbx + return factory + + +def test_build_orchestration_happy_path(): + sbx, captured = _FakeSandbox(), {} + ref = build_agent_image("agent1", 3, {"agent.py": "print(1)"}, "requests==2.34.2", + mirror_url="https://mirror.internal/simple", + mirror_host="mirror.internal", + sandbox_factory=_factory_for(sbx, captured)) + assert ref == "snap-abc" + # build egress = mirror-only (the §0 separation, enforced at build) + assert captured["egress"] == {"deny_out": ["0.0.0.0/0"], "allow_out": ["mirror.internal"]} + # files + lock written; install ran wheels-only; snapshot named per version; killed + assert sbx.written["/home/user/agent.py"] == "print(1)" + assert "--hash=sha256:" in sbx.written["/home/user/requirements.lock"] + assert any("--only-binary=:all:" in c for c in sbx.ran) + assert sbx.snapshot_name == "agent-agent1-v3" + assert sbx.killed is True + + +def test_build_rejects_invalid_requirements_before_sandbox(): + captured = {} + with pytest.raises(DepsError): + build_agent_image("a", 1, {}, "evil==1.0", mirror_url="m", mirror_host="m", + sandbox_factory=_factory_for(_FakeSandbox(), captured)) + assert captured == {} # never even spun a build sandbox + + +def test_build_install_failure_raises_and_kills(): + sbx = _FakeSandbox(install_exit=1) + with pytest.raises(DepsError) as e: + build_agent_image("a", 1, {"agent.py": "x"}, "requests==2.34.2", + mirror_url="m", mirror_host="m", + sandbox_factory=_factory_for(sbx, {})) + assert "install_failed" in {er["code"] for er in e.value.errors} + assert sbx.killed is True # always cleaned up + + +def test_build_rejects_path_traversal(): + sbx = _FakeSandbox() + with pytest.raises(DepsError) as e: + build_agent_image("a", 1, {"../../etc/passwd": "x"}, "requests==2.34.2", + mirror_url="m", mirror_host="m", + sandbox_factory=_factory_for(sbx, {})) + assert "bad_path" in {er["code"] for er in e.value.errors} + + +def test_lockfile_loads_and_has_base_deps(): + lock = ad.load_lockfile() + for name, version in ad.BASE_DEPS: + assert ad._norm(name) in lock and version in lock[ad._norm(name)] + # every entry is hashed + assert all(h.startswith("sha256:") for h in lock[ad._norm(name)][version]) From 8383dd73fd4be4b948581c74cf3f7f3f4db1866d Mon Sep 17 00:00:00 2001 From: MytelligentPRV Date: Sat, 27 Jun 2026 09:27:33 -0700 Subject: [PATCH 2/2] test(code-editing): cover RCE-control specifics for #60 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three pre-merge confirmations: #1 Hash ENFORCEMENT (not just presence): added scripts/deps_live_proof.py — a gated (DEPS_LIVE_PROOF=1) §6 ship-gate that feeds a TAMPERED hash to the real pip command and asserts the install is rejected. Proven live: pip computed the real wheel hash (d909fccc...), compared to the lockfile's tampered 0000..., and failed the install ('do not match'). A swapped artifact IS caught. #2 Parser bypass-resistance (unit tests): -r/recursive mixed with a valid line → rejected, resolve_install_set returns [] (no partial build, no sandbox spun); whitespace around '==' fail-closed (rejected); PEP-503 case+separator normalization consistent (Python_Dateutil / python.dateutil / PYTHON__DATEUTIL all → one key) so the allowlist can't be dodged by casing/spacing. #3 Base-deps bake is REAL (live-proven, in the gated script): pipeline build → create_snapshot → boot a run sandbox (gateway-only egress) with httpx 0.28.1 + wayforth-sdk 0.9.0 importable and PyPI unreachable (000 ec=35). This is the §3 prerequisite that unblocks Step 2's run-egress lock — not assumed. The gated proof also re-checks the two-allowlist separation (build can't reach gateway; run can't reach mirror). 35 agent_deps tests; full suite green (584 passed). --- apps/api/scripts/deps_live_proof.py | 120 ++++++++++++++++++++++++++++ apps/api/tests/test_agent_deps.py | 38 +++++++++ 2 files changed, 158 insertions(+) create mode 100644 apps/api/scripts/deps_live_proof.py diff --git a/apps/api/scripts/deps_live_proof.py b/apps/api/scripts/deps_live_proof.py new file mode 100644 index 0000000..0300168 --- /dev/null +++ b/apps/api/scripts/deps_live_proof.py @@ -0,0 +1,120 @@ +"""scripts/deps_live_proof.py — §6 ship-gate for the dependency pipeline (LIVE). + +Runs the real services.agent_deps pipeline against real E2B sandboxes + real pip. +These are must-pass-BEFORE-user-visible (same standard as the run-token rotation +proof) — they exercise behavior unit tests can't fake (pip's hash enforcement, a +booted snapshot, the two egress allowlists). + +Gated — does nothing unless DEPS_LIVE_PROOF=1 and E2B_API_KEY is set: + + DEPS_LIVE_PROOF=1 E2B_API_KEY=... [DEPS_MIRROR_URL=...] \ + /app/.venv/bin/python -m scripts.deps_live_proof + +Without a private mirror yet, PyPI stands in as the index (DEPS_MIRROR_URL / +allowed hosts default to PyPI). In prod these point at the private mirror. + +Proves: + #1 hash-mismatch → pip rejects the install (a swapped artifact is caught). + #3 base deps build → snapshot → boot a run sandbox (gateway-only egress) with + httpx + wayforth-sdk importable and PyPI unreachable (unblocks Step 2). + §0 two-allowlist separation: build can't reach the gateway; run can't reach the mirror. +TODO(canary): a wheel that POSTs to an external host → blocked at run egress. +""" +from __future__ import annotations + +import os +import sys + +from services.agent_deps import ( + BASE_DEPS, _norm, build_requirements_lock, load_lockfile, pip_install_command, +) + +MIRROR_URL = os.environ.get("DEPS_MIRROR_URL", "https://pypi.org/simple") +MIRROR_HOSTS = os.environ.get("DEPS_MIRROR_HOSTS", "pypi.org,files.pythonhosted.org").split(",") +GATEWAY_HOST = os.environ.get("WAYFORTH_GATEWAY_HOST", "gateway.wayforth.io") + + +def _net(allow): + from e2b import SandboxNetworkOpts + return SandboxNetworkOpts(deny_out=["0.0.0.0/0"], allow_out=list(allow)) + + +def _run(sbx, cmd, timeout=150): + return sbx.commands.run(cmd + " ; echo EXIT=$?", timeout=timeout) + + +def main() -> int: + if os.environ.get("DEPS_LIVE_PROOF") != "1" or not os.environ.get("E2B_API_KEY"): + print("deps_live_proof: skipped (set DEPS_LIVE_PROOF=1 and E2B_API_KEY).") + return 0 + + from e2b import Sandbox + + lock = load_lockfile() + base = [(_norm(n), v, lock[_norm(n)][v]) for n, v in BASE_DEPS] + good_lock = build_requirements_lock(base) + bad_lock = build_requirements_lock([("httpx", "0.28.1", ["sha256:" + "0" * 64])]) + pip = pip_install_command(MIRROR_URL) + failures = [] + + # #1 — hash mismatch must be rejected + b = Sandbox.create(timeout=180, network=_net(MIRROR_HOSTS)) + try: + # §0: build sandbox cannot reach the gateway + g = _run(b, f'curl -sS -o /dev/null -w "%{{http_code}}" --max-time 8 https://{GATEWAY_HOST}/status') + if "ec=35" not in g.stdout and "000" not in g.stdout: + failures.append(f"#0 build reached gateway: {g.stdout!r}") + b.files.write("/home/user/requirements.lock", bad_lock) + r = _run(b, pip) + if "do not match" not in (r.stdout + r.stderr).lower(): + failures.append(f"#1 hash mismatch NOT rejected: {(r.stdout + r.stderr)[-300:]!r}") + else: + print("#1 PASS — pip rejected the tampered hash") + finally: + b.kill() + + # #3 — base build → snapshot → boot run sandbox, importable, PyPI blocked + b2 = Sandbox.create(timeout=300, network=_net(MIRROR_HOSTS)) + sid = None + try: + b2.files.write("/home/user/requirements.lock", good_lock) + r2 = _run(b2, pip, timeout=240) + if "EXIT=0" not in r2.stdout: + failures.append(f"#3 base install failed: {(r2.stderr or '')[-300:]!r}") + snap = b2.create_snapshot(name="wf-deps-shipgate") + sid = getattr(snap, "snapshot_id", None) or getattr(snap, "template_id", None) + finally: + b2.kill() + + if sid: + rn = Sandbox.create(sid, timeout=120, network=_net([GATEWAY_HOST])) + try: + imp = _run(rn, 'python3 -c "import httpx;print(httpx.__version__)" && pip show wayforth-sdk | grep -i ^version') + if "EXIT=0" not in imp.stdout: + failures.append(f"#3 base deps not importable in run sandbox: {imp.stdout!r}") + else: + print(f"#3 PASS — base deps importable in run sandbox: {imp.stdout.splitlines()[:2]}") + # §0: run sandbox cannot reach the mirror/PyPI + pp = _run(rn, f'curl -sS -o /dev/null -w "%{{http_code}}" --max-time 8 {MIRROR_URL}') + if "ec=35" not in pp.stdout and "000" not in pp.stdout: + failures.append(f"#0 run reached mirror: {pp.stdout!r}") + else: + print("#0 PASS — run sandbox blocked from the mirror; build blocked from gateway") + finally: + rn.kill() + try: + Sandbox.delete_snapshot(sid) + except Exception: + pass + + if failures: + print("DEPS LIVE PROOF: FAIL") + for f in failures: + print(" -", f) + return 1 + print("DEPS LIVE PROOF: PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/api/tests/test_agent_deps.py b/apps/api/tests/test_agent_deps.py index fbd8cfe..462abee 100644 --- a/apps/api/tests/test_agent_deps.py +++ b/apps/api/tests/test_agent_deps.py @@ -77,6 +77,44 @@ def test_too_many_deps_rejected(): assert "too_many" in _codes(validate_requirements(body, LOCK)[1]) +# ── #2: parser can't be bypassed by requirements-file trickery ────────────────── + +def test_recursive_flag_rejected_and_no_partial_build(): + # -r mixed with a valid line: rejected, and resolve returns [] (nothing built) + pins, errors = validate_requirements("requests==2.34.2\n-r /etc/secrets.txt", LOCK) + assert "unsupported" in _codes(errors) + iset, errs = resolve_install_set("requests==2.34.2\n-r /etc/secrets.txt") + assert iset == [] and errs # any error ⇒ no install set, no build + + +@pytest.mark.parametrize("line", ["requests ==2.34.2", "requests== 2.34.2", + "requests == 2.34.2", " requests==2.34.2 "]) +def test_whitespace_around_operator_failclosed(line): + # pip tolerates whitespace; we reject (fail-closed) so it can't dodge the allowlist + pins, errors = validate_requirements(line, LOCK) + if line.strip() == "requests==2.34.2": # the fully-trimmed one is valid + assert errors == [] + else: + assert errors # internal whitespace ⇒ rejected + + +def test_separator_and_case_normalization_pep503(): + # casing + separators normalize to ONE key, so the allowlist can't be dodged + lock = {"python-dateutil": {"2.9.0.post0": ["sha256:x"]}} + for variant in ["python-dateutil==2.9.0.post0", "Python_Dateutil==2.9.0.post0", + "python.dateutil==2.9.0.post0", "PYTHON__DATEUTIL==2.9.0.post0"]: + pins, errors = validate_requirements(variant, lock) + assert errors == [] and pins[0][0] == "python-dateutil", variant + + +def test_build_rejects_recursive_flag_before_sandbox(): + captured = {} + with pytest.raises(DepsError): + build_agent_image("a", 1, {}, "-r secrets.txt", mirror_url="m", mirror_host="m", + sandbox_factory=_factory_for(_FakeSandbox(), captured)) + assert captured == {} # never spun a sandbox + + # ── resolve_install_set: base closure + user, conflicts ───────────────────────── def test_base_deps_always_included():