Skip to content

fix(security): isolate web E2E commands and readiness probes - #1347

Merged
seonghobae merged 34 commits into
mainfrom
fix/sandboxed-web-e2e-isolation-clean
Aug 31, 2026
Merged

fix(security): isolate web E2E commands and readiness probes#1347
seonghobae merged 34 commits into
mainfrom
fix/sandboxed-web-e2e-isolation-clean

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • require Linux bubblewrap isolation by default for backend, frontend, and E2E commands
  • mount a read-only runtime root with a single writable workspace and scoped temporary homes
  • reject non-loopback readiness destinations and redirects to fail closed against SSRF
  • retain explicit disabled mode only for trusted local debugging and document the boundary

Verification

  • python3 -m pytest -q tests/test_sandboxed_web_e2e.py tests/test_repository_branch_coverage_execution_sandboxes.py tests/test_opencode_existing_approval_gate.py tests/test_opencode_security_boundaries.py (92 passed)
  • python3 -m ruff check scripts/ci/sandboxed_web_e2e.py tests/test_sandboxed_web_e2e.py tests/test_repository_branch_coverage_execution_sandboxes.py
  • git diff --check

This clean PR contains only the SSRF/isolation change; it supersedes stale #1342 without its unrelated workflow reverts.


Open in Devin Review

Summary by CodeRabbit

  • 새로운 기능

    • 웹 검증 명령에 Linux bubblewrap 기반 격리 실행을 지원합니다.
    • 격리 환경에서는 읽기 전용 시스템 영역과 /workspace만 수정할 수 있습니다.
    • 실행 결과에 요청된 격리 모드와 실제 사용된 백엔드가 기록됩니다.
  • 버그 수정

    • 준비 상태 확인이 로컬 주소와 리다이렉트만 허용하도록 강화되었습니다.
    • 잘못된 URL과 격리 실행 불가 상황을 구분된 오류 코드로 보고합니다.
    • 격리 작업공간 복사 실패와 실행 파일 검증 우회가 안전하게 차단됩니다.
  • 보안

    • 샌드박스 외부로 연결되는 심볼릭 링크와 자격 증명 파일 복사가 차단됩니다.
  • 문서

    • 웹 명령 격리 방식, 제한 사항 및 디버깅 모드를 설명하는 문서를 추가했습니다.

@seonghobae
seonghobae enabled auto-merge (squash) August 26, 2026 00:07
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ff05960-a031-4b43-be64-df0013f22c1b

📥 Commits

Reviewing files that changed from the base of the PR and between ed05b55 and d0c869c.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • docs/doctoring/sandboxed-web-command-isolation.md
  • docs/product-technical-gap-baseline.md
  • scripts/ci/sandboxed_verify.py
  • scripts/ci/sandboxed_web_e2e.py
  • tests/test_sandboxed_verify.py
  • tests/test_sandboxed_web_e2e.py
📝 Walkthrough

Walkthrough

웹 검증 helper가 기본 Linux bubblewrap 격리를 사용한다. 실행 명령은 /workspace와 제한된 읽기 전용 시스템 경로를 사용한다. workspace 복사는 자격 증명 파일과 외부 탈출 symlink를 거부한다. readiness URL은 loopback과 유효한 포트만 허용한다.

Changes

웹 검증 명령 격리

Layer / File(s) Summary
격리 backend와 명령 구성
scripts/ci/sandboxed_web_e2e.py, tests/test_sandboxed_web_e2e.py
--isolation 인자와 bubblewrap capability probe를 추가했다. /workspace writable mount, 환경 변수 매핑, 실행 파일 경로 제한을 적용한다.
workspace 복사와 경계 검사
scripts/ci/sandboxed_verify.py, tests/test_sandboxed_verify.py
자격 증명 파일을 복사에서 제외한다. dotenv 템플릿 allowlist를 적용한다. 외부로 탈출하는 symlink와 순환 참조를 거부한다.
서비스 실행과 readiness 결과
scripts/ci/sandboxed_web_e2e.py, tests/test_sandboxed_web_e2e.py, tests/test_repository_branch_coverage_execution_sandboxes.py
backend, frontend, E2E 명령을 격리 실행한다. readiness URL의 host와 포트를 사전 검증한다. 실패 코드와 격리 metadata를 결과에 기록한다.
격리 동작 계약 문서화
CHANGELOG.md, docs/doctoring/sandboxed-web-command-isolation.md, docs/doctoring/sandboxed-web-readiness-loopback-boundary.md, docs/product-technical-gap-baseline.md
격리 mount, workspace 경계, readiness 검증, 오류 코드와 관련 변경 내역을 문서화했다.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟡 Moderate · up to ed05b

The PR strengthens sandboxing and SSRF protection, but a valid workspace containing an ancestor symlink can currently fail before backend, frontend, or E2E verification starts. This concrete execution failure should be fixed or explicitly accepted before merge; the documentation and test portability issues should also be followed up.

Suggested reviewers: cursoragent

Sequence Diagram(s)

sequenceDiagram
  participant main
  participant isolation_backend
  participant backend_service
  participant frontend_service
  participant wait_for_url
  participant e2e_command
  main->>isolation_backend: bubblewrap capability 확인
  isolation_backend-->>main: 격리 backend 반환
  main->>backend_service: 격리된 backend 명령 실행
  main->>frontend_service: 격리된 frontend 명령 실행
  main->>wait_for_url: loopback readiness URL 확인
  wait_for_url-->>main: 준비 상태 반환
  main->>e2e_command: 격리된 E2E 명령 실행
  e2e_command-->>main: 결과와 isolation metadata 반환
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 웹 E2E 명령 격리와 readiness probe 변경을 정확히 요약합니다. 보안 목적과 주요 변경 범위를 간결하게 전달합니다.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 114 functions across 5 files. (4 skipped: 4…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 114 functions across 5 files. (4 skipped: 4 unsupported.)

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sandboxed-web-e2e-isolation-clean

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@opencode-agent
opencode-agent Bot disabled auto-merge August 26, 2026 02:11
seonghobae pushed a commit that referenced this pull request Aug 30, 2026
Reconfirmed the three tracked PRs' opencode-review failures are the
known async-verdict wait state, not a defect. Re-ran .github#1438's
noema-review once after the sidecar's gateway-preflight hit the
known 120s/0-byte hang signature again (shared review infra, not this
PR's diff). Kicked off parallel research for naruon G-06's next
increment, G-15's next increment, and .github#1347's SSRF merge
conflict; results land in a follow-up entry.
…-isolation-clean

# Conflicts:
#	CHANGELOG.md
#	scripts/ci/sandboxed_web_e2e.py
#	tests/test_sandboxed_web_e2e.py

Copy link
Copy Markdown
Contributor Author

Resolved the stale merge conflict against current main and pushed a merge commit (7ac8298b) onto this PR's branch (fix/sandboxed-web-e2e-isolation-clean).

Key finding — main already has a superior readiness-URL SSRF fix, landed independently while this PR was open (docs/adr/0004-sandboxed-web-readiness-loopback-boundary.md, docs/doctoring/sandboxed-web-readiness-loopback-boundary.md, explicitly noting it "lands the same buyer-facing repair as #1244"). main's require_loopback_readiness_url (in scripts/ci/sandboxed_web_e2e.py) is strictly more thorough than this PR's own validate_readiness_url:

  • rejects userinfo-confused URLs (http://user@127.0.0.1/)
  • resolves literal localhost via socket.getaddrinfo and requires every answer to be loopback (defeats a poisoned-hosts-file bypass) — this PR's version trusted the literal string "localhost" without resolving it
  • unwraps IPv4-mapped IPv6 (::ffff:8.8.8.8) before the loopback check
  • covers ::, [::1], trailing-dot localhost., and case-insensitive scheme

Resolution: dropped this PR's validate_readiness_url entirely and kept main's require_loopback_readiness_url (and its two helpers _require_loopback_ip_text/_require_resolved_loopback_hostname). This PR's genuinely unique contribution — the Linux bubblewrap OS-isolation layer (isolation_backend, isolated_command, _sandbox_environment, the --isolation flag, /workspace-only writable mount, executable-path bind-root validation) — is untouched and fully preserved. I also kept one small, real improvement this PR made that main lacks: readiness-URL validation and isolation-backend resolution now both run before any service starts (clean exit 125/126 diagnostics), whereas main's current main() would let a ValueError from an invalid URL propagate as an uncaught traceback after services are already running. I also kept this PR's fix for wait_for_url busy-looping without a backoff on a persistent non-2xx/3xx/4xx (>=500) response — added time.sleep(1) there, matching the existing retry-on-URLError behavior.

In tests/test_sandboxed_web_e2e.py, main's dedicated test_wait_for_url_rejects_non_loopback_and_confused_deputy_targets and test_localhost_resolution_must_stay_loopback (added for the ADR-0004 fix) already cover the external-hostname/userinfo/localhost-poisoning matrix, so I removed four assertions from this PR's test_wait_helpers_and_service_cleanup_edges that had become exact duplicates of that new coverage, keeping main's broader address-form list (localhost., [::1], IPv4-mapped IPv6, explicit https://) in the merged function instead.

CHANGELOG.md's ## [Unreleased] section conflicted only because both branches added adjacent entries; reworded this PR's entry to credit the existing loopback boundary rather than re-claim it, while still describing the isolation feature and the before-service-start validation ordering accurately.

Test evidence on the merged tree:

  • tests/test_sandboxed_web_e2e.py tests/test_repository_branch_coverage_execution_sandboxes.py tests/test_opencode_existing_approval_gate.py tests/test_opencode_security_boundaries.py: 113 passed
  • Full suite (PYTHONPATH=. python3 -m pytest tests -q): 1912 passed, 1 skipped, 21 subtests passed
  • coverage run -m pytest tests -q && coverage report --show-missing: scripts/ci/sandboxed_web_e2e.py 259 stmts / 86 branches — 100%; scripts/ci TOTAL 10038 stmts / 3954 branches — 100%
  • interrogate -v scripts/ci/sandboxed_web_e2e.py and full scripts/ci: 100.0%
  • python3 -m py_compile on the touched .py files: clean
  • git diff --cached --check: clean, no leftover conflict markers or whitespace errors

Generated by Claude Code

…-isolation-clean

# Conflicts:
#	CHANGELOG.md
#	scripts/ci/sandboxed_web_e2e.py
#	tests/test_sandboxed_web_e2e.py
devin-ai-integration[bot]

This comment was marked as resolved.

…clean' into fix/sandboxed-web-e2e-isolation-clean

# Conflicts:
#	CHANGELOG.md
#	tests/test_sandboxed_web_e2e.py
seonghobae pushed a commit that referenced this pull request Aug 30, 2026
naruon G-06's human-correction slice shipped (naruon#1486). .github#1347's
SSRF/isolation merge conflict resolved via ordinary merge commit, including
reconciling with a concurrent session that had already pushed an equivalent
resolution to the same branch (adopted their CHANGELOG wording and dropped
a harmless duplicate test assertion; both sessions independently found and
fixed the same empty-readiness-URL regression). G-15 scoped but not yet
implemented.

Copy link
Copy Markdown
Contributor Author

"Hop boundary remains closed" info note and, more importantly, the sibling security finding Devin flagged on the parallel PR #1280 ("Nested directory links escape sandbox") — that PR shares this file's identical hop-walk design, so I checked whether it applied here too. It does, and I reproduced it directly against this file before fixing (see below).

Root cause

The whole-target os.path.normpath() collapse treats a symlink's raw readlink() target as one atomic string, so it can't notice when an intermediate component of that target is itself a symlink. Concretely: a symlink self-alias -> . (its own parent — the repo root; completely legitimate and safe on its own) combined with link -> self-alias/../outside-secret.txt was not caught: os.path.normpath("self-alias/../outside-secret.txt") cancels self-alias against the following .. purely textually, collapsing to outside-secret.txt (looks safe, in-repo). Resolved for real, one component at a time: following self-alias lands at the repo root itself (zero depth), so the very next .. immediately exits the repo — an actual escape a single normpath call can never see, because it never re-examines whether an intermediate segment needs its own resolution first.

Fix (297bcea3)

Replaced the whole-target lexical collapse with a component-by-component walk: pop one path segment at a time, and if the accumulated position is itself a symlink, substitute its target's components back onto the front of the work queue instead of treating the original target string as one atomic unit. is_symlink() gets re-checked after every single segment, closing the gap.

As a side effect this also cleanly resolves the hop-limit off-by-one from my earlier fe237c4f fix without any special-casing: a hop's budget is now spent only when a symlink is actually dereferenced (not once per loop iteration), so a chain of exactly MAXIMUM_SYMLINK_HOPS real symlinks needs no +1 adjustment.

Verification

All existing symlink tests pass unchanged (escape, absolute, internal, dangling, excluded-by-DEFAULT_IGNORE, cycle, hop-limit boundary). Added two new regression tests: the nested-alias escape (must raise) and a legitimate cross-directory .. traversal that stays in-bounds (must still be accepted — needed to cover the successful-..-pop branch).

Full suite: 1969 passed, 1 skipped, 21 subtests passed; scripts/ci/sandboxed_verify.py at 100% statement/branch coverage and 100% docstrings.


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

claude added 2 commits August 30, 2026 15:08
…ank commands

Devin flagged five findings on this PR; four are confirmed real bugs, fixed
here with fail-before/pass-after regression tests. The fifth (readiness
probes sharing the runner's loopback network namespace) is a real gap but
has no small, non-regressing fix available -- posted as a separate PR
comment for maintainer review instead of a speculative code change.

1. sandboxed_web_e2e.isolated_command rejected valid repository launchers
   (e.g. ./gradlew): shutil.which resolves any path-separator-bearing
   command against the *wrapper process's* own cwd, never against the
   copied repository's cwd the caller actually passes in. Added
   _resolve_isolated_executable, which resolves an explicit-path argv[0]
   against the sandboxed cwd instead, and widened the bind-root check to
   also permit executables inside sandbox_root (mounted at /workspace),
   while still fail-closed rejecting path traversal and external paths.

2. sandboxed_verify's DEFAULT_IGNORE ".env.*" glob excluded committed,
   secret-free templates (.env.example, .env.sample, .env.template) right
   along with real dotenv credential files. Added
   DEFAULT_ENV_TEMPLATE_ALLOWLIST and _ignore_with_env_template_allowlist,
   which wraps shutil.ignore_patterns to spare those specific names.

3. _probe_isolation_capability resolved its probe shell via
   shutil.which("sh") against the caller's own PATH, which can return a
   binary outside every root isolated_command actually bind-mounts (e.g. a
   PATH entry shadowing sh with a home-directory executable). That shell is
   invisible inside the sandbox, so a real working bubblewrap install fails
   the probe. Added _probe_shell(), which only picks from PROBE_SHELL_PATHS
   (/bin/sh, /usr/bin/sh) -- the same mounted roots isolated_command uses --
   and fails clearly if neither exists.

4. A whitespace-only backend/frontend/e2e command made isolated_command
   raise ValueError("command must not be empty"), which the call site's
   except clause (catching only RuntimeError) let propagate as an uncaught
   traceback instead of the documented isolation-rejection exit code 126.
   The except clause now also catches ValueError.

Validation: PYTHONPATH=. python3 -m pytest tests -q -> 1942 passed, 1
skipped, 21 subtests passed. coverage on scripts/ci/sandboxed_verify.py and
scripts/ci/sandboxed_web_e2e.py -> 100% statement+branch. Full-repo
coverage and interrogate docstring coverage both 100%.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX
…clean' into fix/sandboxed-web-e2e-isolation-clean

Copy link
Copy Markdown
Contributor Author

Devin findings 1, 2, 4, 5 — triaged and fixed

Picked up Devin's 5 new findings independently (parallel session). Findings 1, 2, 4, and 5 are confirmed real bugs and are fixed in commit fe68c2fa (merged to head at 89d0a453, fast-forwarded cleanly onto the concurrent symlink-walk fix already on the branch). Finding 3 is a real but architectural gap — see the separate comment I'm posting for it; no code pushed for that one.

1. Repository launchers rejected (sandboxed_web_e2e.py, isolated_command) — confirmed, fixed

shutil.which resolves any path-separator-bearing command (e.g. ./gradlew) against the wrapper process's own cwd, never the cwd argument callers pass in — so a repo-local launcher was always resolved (or missed) against the wrong directory and rejected with exit 126. Added _resolve_isolated_executable, which resolves an explicit-path argv[0] against the sandboxed cwd instead, and widened the bind-root check to also permit executables inside sandbox_root (mounted at /workspace). Path traversal and external absolute paths are still fail-closed rejected.

Tests added: a real ./gradlew-style launcher inside the copied repo (must succeed), a ../../-traversal case and an absolute external-path case (both must still raise "outside the isolated bind roots"), plus a missing-repo-local-path case (must raise "could not be resolved").

2. Environment templates vanish from workspaces (sandboxed_verify.py, DEFAULT_IGNORE) — confirmed, fixed

The .env.* glob excluded committed, secret-free templates (.env.example, .env.sample, .env.template) along with real dotenv credential files. Added DEFAULT_ENV_TEMPLATE_ALLOWLIST + _ignore_with_env_template_allowlist, which wraps shutil.ignore_patterns to spare those specific names while still excluding .env, .env.local, .env.production, etc.

Test added alongside the existing test_copy_workspace_excludes_credential_bearing_paths: preserves the three template names, still excludes the three secret-bearing ones.

4. Custom shell falsely disables isolation (sandboxed_web_e2e.py, _probe_isolation_capability) — confirmed, fixed

The probe resolved its shell via shutil.which("sh") against the caller's own PATH, which can return a binary outside every root isolated_command actually bind-mounts (e.g. a PATH entry shadowing sh with a home-directory executable). That shell is invisible inside the sandbox, so a real working bubblewrap install failed the probe with exit 126. Added _probe_shell(), which only picks from PROBE_SHELL_PATHS (/bin/sh, /usr/bin/sh — the same mounted roots isolated_command uses) and raises a clear error if neither exists.

Test added: PATH monkeypatched so a home-directory executable shadows sh, asserting the probe still succeeds and uses a mounted path, never the shadowed one.

5. Blank commands escape coded failure (sandboxed_web_e2e.py, isolated_command / main) — confirmed, fixed

A whitespace-only command made isolated_command raise ValueError("command must not be empty"), which the call site's except RuntimeError didn't catch — it propagated as an uncaught traceback instead of the documented isolation-rejection exit code 126. The except clause now also catches ValueError.

Test added: whitespace-only --backend-cmd under required isolation asserts exit code 126, no service starts, and no Traceback in stderr/stdout.

Validation

All four fixes have fail-before/pass-after regression tests (verified by re-running each new test against the pre-fix source before restoring the fix). On the merged tree at 89d0a453:

  • PYTHONPATH=. python3 -m pytest tests -q1977 passed, 1 skipped, 21 subtests passed
  • coverage run -m pytest tests -q && coverage report100% statement+branch on scripts/ci/sandboxed_verify.py, scripts/ci/sandboxed_web_e2e.py, and repo-wide total
  • python3 -m interrogate -v100.0% docstring coverage

No existing tests were modified — all additions are new test functions.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Devin finding 3 — "Readiness probes reach runner services" — real gap, no small fix; needs a maintainer decision

Confirmed as real. Not pushing a code change for it — writing this up instead per the task's own instruction for this finding.

What's exploitable: require_loopback_readiness_url (used by both --backend-ready-url and --frontend-ready-url) only checks that the target is some loopback host:port — it never ties the URL to the specific service this sandbox invocation actually started. Bubblewrap here does not pass --unshare-net (by design: the backend and frontend need real network access to each other and to the E2E runner), so the sandbox's network namespace is the same one the host CI runner uses. Anything else already listening on 127.0.0.1 on that runner — another job's service, an agent/metadata sidecar, an internal admin port — is reachable through the exact same "loopback readiness check" code path. An attacker who influences the readiness URL (workflow input, or a compromised launch config) can point it at an arbitrary loopback port and get its HTTP status/behavior reflected into the readiness poll and CI logs, i.e. an SSRF-shaped probe of the runner's other loopback services, not just this sandbox's own service.

Why there's no small patch here:

  • The obvious fix — "only allow the port the operator's own service is bound to" — has no reliable source for "the port" today. --backend-cmd/--frontend-cmd are arbitrary shell strings (npm run dev, ./gradlew bootRun, a Python script with a hardcoded or env-derived port, etc.); there's no dependable way to parse "the port this will bind" out of an arbitrary launch command without false rejections that would break real, currently-working pipelines. That rules out a purely CLI/validation-level fix.
  • A more precise version — correlate the readiness URL's port against sockets actually opened by the started service's process tree (walk /proc/<pid>/... and its process group for LISTEN sockets, matching by inode) — is technically possible on Linux and wouldn't need a new CLI contract, but it's a real chunk of new, timing-sensitive logic (process-group walking, /proc parsing, races against slow-starting services) with its own failure modes. That's a legitimate improvement but not a "minimal, confident, non-regressing" patch to land inside this batch of fixes — it deserves its own dedicated PR and review, ideally from whoever owns this script's threat model.
  • The structural fix — give each sandboxed invocation its own network namespace (--unshare-net + something like slirp4netns/pasta or a veth+NAT setup) while still letting backend ↔ frontend ↔ E2E runner talk to each other and to whatever external hosts they legitimately need — is the actual closure of the gap, but it's a genuine architecture change to how this script does networking, not a patch to require_loopback_readiness_url.

Recommendation: leave this open for a maintainer/threat-model owner to decide between (a) accepting the residual risk as documented (the loopback check already blocks public hosts, cloud metadata, unspecified binds, and userinfo-confusion — this is specifically about other loopback services on the same runner, a narrower and more CI-runner-specific threat), (b) commissioning the /proc-based port-provenance check as its own reviewed change, or (c) the full network-namespace redesign. Happy to implement whichever direction is chosen.


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

…seen-forever

Devin flagged "Valid symlink paths are rejected": a symlink referenced
twice in one chain -- once fully resolved before the second reference is
ever reached, not a real loop -- was incorrectly treated as a cycle,
because the walk's `seen` set recorded every symlink ever dereferenced for
the whole top-level candidate and never removed one once its resolution
completed. Reproduced directly: `shared -> real_dir`, `link ->
"shared/../shared/file.txt"` (a path the OS resolves without issue,
referencing `shared` twice non-recursively) raised "workspace symlink
could not be resolved" before this fix.

Fix: restructured the walk from an iterative work-queue with a permanent
`seen` set into a genuinely recursive component resolver with an `active`
set -- a symlink is added to `active` only while its own target is being
resolved (a fresh recursive call) and removed again as soon as that call
returns successfully. A cycle is then precisely "a symlink that, directly
or through others, points back to itself while still being resolved",
which is what `step in active` now tests, rather than "was ever
dereferenced anywhere in this chain". The true self-loop test
(`a -> b -> a`) still raises, since `a` is still on the active call stack
when it is encountered again.

Verified: all existing symlink tests pass unchanged (escape, absolute,
internal, dangling, excluded-by-DEFAULT_IGNORE, cycle, hop-limit
boundary), plus a new regression test for the shared-non-cyclic-reference
case. Full suite: 1978 passed, 1 skipped, 21 subtests passed;
sandboxed_verify.py at 100% statement/branch coverage and 100% docstrings.

Copy link
Copy Markdown
Contributor Author

"Valid symlink paths are rejected" — confirmed and fixed in bd0697aa.

Reproduced directly before fixing: shared -> real_dir, link -> "shared/../shared/file.txt" — a path the OS resolves without any issue, referencing shared twice non-recursively — raised workspace symlink could not be resolved. Root cause: the walk's seen set recorded every symlink dereferenced anywhere in the whole candidate's resolution and never removed one once its resolution completed, so a symlink fully resolved once, earlier, was indistinguishable from one currently being resolved as part of a real loop.

Fix: restructured the walk from an iterative work-queue with a permanent seen set into a genuinely recursive component resolver with an active set — a symlink is added to active only while its own target is being resolved (a fresh recursive call) and removed again as soon as that call returns successfully. A cycle is now precisely "a symlink that, directly or through others, points back to itself while still being resolved" (step in active), not "was ever dereferenced anywhere in this chain." The genuine self-loop test (a -> b -> a) still raises correctly, since a is still on the active call stack when it's encountered again.

Verified: all existing symlink tests pass unchanged (escape, absolute, internal, dangling, excluded-by-DEFAULT_IGNORE, cycle, hop-limit boundary), plus a new regression test for the shared-non-cyclic-reference case. Full suite: 1978 passed, 1 skipped, 21 subtests passed; scripts/ci/sandboxed_verify.py at 100% statement/branch coverage and 100% docstrings.

(Porting the same fix to the parallel PR #1280, which shares this design and had the identical latent issue plus a design question it raised — its "accept self-contained cycles" behavior turns out to be unsound once resolution can genuinely continue past a cyclic symlink into further path components, so I'm changing it to reject cycles too, matching this PR's behavior — details there.)


Generated by Claude Code

seonghobae pushed a commit that referenced this pull request Aug 30, 2026
… tracking

Two problems in one fix, both stemming from the same design flaw as the
identical hop-walk in the parallel PR #1347:

1. The nested-alias escape fix from an earlier, uncommitted pass on this
   branch was lost when the worktree was cleaned up before pushing. Redone
   here as part of this rewrite: reproduced directly (self-alias -> ".",
   combined with a second symlink targeting "self-alias/../outside-secret.txt",
   was not caught -- the whole target string collapsed lexically instead of
   re-resolving the intermediate alias component).

2. "Accept a self-contained cycle" cannot be done soundly once symlinks are
   resolved component-by-component: a genuine cycle has no well-defined
   resolved position to hand back to a caller that needs to keep resolving
   further path components past it, so treating it as merely "contained"
   risks silently accepting an escape hiding in components that never get
   processed because the walk stops early. Devin's "Valid symlink paths are
   rejected" finding on the sibling PR #1347 also applies here in a
   different, more consequential way: this file's old flat "visited"
   set treated "resolved once, earlier" the same as "currently resolving",
   so a legitimate same-symlink-twice reference would have hit the (former)
   accept-on-revisit branch by coincidence -- for the wrong reason, and
   silently, without validating whatever came after it in the chain.

Fix: rewrote _validate_contained_symlink_cycle as a thin entry point over a
new recursive _resolve_repository_symlink_components, matching the
component-by-component, active-set design just applied to #1347. A symlink
is added to `active` only while its own target is being resolved and
removed once that recursive call returns successfully, so the same symlink
referenced twice non-recursively (fully resolved once, then referenced
again later) is accepted, while a genuine self-referential cycle raises
RepositoryPathBoundaryError -- changing this file's cycle policy from
"accept if contained" to "reject", matching the sibling PR's simpler,
already-established, more conservative behavior. Updated the two tests
that asserted cycle-acceptance to assert rejection instead (renamed
accordingly), and added a regression test for the shared-non-cyclic
reference case.

Verified: nested-alias escape now caught; shared-reference case now
accepted; all other existing symlink tests (escape, absolute, internal,
excluded-by-ignore, hop-limit boundary) pass unchanged. Full suite:
1997 passed, 1 skipped, 21 subtests passed; sandboxed_verify.py at 100%
statement/branch coverage and 100% docstrings.
claude added 3 commits August 30, 2026 15:26
Devin flagged (thread PRRT_kwDOS_C14s6dh6My) that sandboxed_verify.py's
main() calls copy_workspace() with no except around it: a symlink-escape
rejection propagated as an uncaught ValueError, printing a raw Python
traceback and exiting with Python's default uncaught-exception status
instead of this module's own clean "sandboxed-verify: ..." message and
coded exit (e.g. 124 for the timeout path). --keep-sandbox still retains
the rejected copy either way, which matches its documented "for
debugging" purpose -- not a bug to fix here.

sandboxed_web_e2e.py calls the same copy_workspace() with the identical
gap, found independently while fixing the sibling script; fixed the same
way (exit 125, matching the code already used for its other ValueError
rejections like an invalid readiness URL).

Added a regression test per script asserting a clean exit 125, no
traceback, and a still-emitted result payload.
…t; resolve bare commands via relative PATH entries

Devin review findings on PR #1347:

- sandboxed_verify.py: _ignore_with_env_template_allowlist merged
  DEFAULT_IGNORE and the caller's extra_ignores into one combined pattern
  set before restoring DEFAULT_ENV_TEMPLATE_ALLOWLIST names, so an explicit
  --ignore .env.example (or any caller-supplied extra_ignores entry) was
  silently overridden and the file still landed in the writable sandbox.
  Now builds two separate ignore functions -- one from DEFAULT_IGNORE alone
  (whose broad .env.* glob the allowlist exists to except from) and one
  from extra_ignores alone (never overridden) -- and only restores a name
  matched solely by the former.

- sandboxed_web_e2e.py: _resolve_isolated_executable resolved a bare
  PATH-searched command purely via shutil.which(), which always resolves a
  relative PATH entry against the wrapper process's own cwd with no way to
  override that. A PATH with a relative entry meant to be read relative to
  the copied repository (e.g. PATH=bin:/usr/bin) therefore failed isolation
  (exit 126) even when the tool legitimately existed under the sandboxed
  cwd. Falls back to a new _which_relative_to_cwd that mirrors
  shutil.which's PATH-splitting/executable-bit checks by hand, anchoring
  relative entries at cwd; a relative entry that would resolve outside
  sandbox_root is skipped without touching the real filesystem, keeping the
  existing fail-closed behavior for a traversal PATH like ../../...

Regression tests added for both, verified fail-before/pass-after against a
temporary revert of each fix. Full suite: 1982 passed, 1 skipped, 21
subtests passed. scripts/ci/sandboxed_verify.py and
scripts/ci/sandboxed_web_e2e.py: 100% statement+branch coverage, 100%
docstring coverage (interrogate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX
…clean' into fix/sandboxed-web-e2e-isolation-clean-local

Copy link
Copy Markdown
Contributor Author

Triage of the 2 new Devin findings

1. "Explicit exclusions cannot remove environment templates" — CONFIRMED, fixed.

scripts/ci/sandboxed_verify.py, _ignore_with_env_template_allowlist merged DEFAULT_IGNORE + extra_ignores into one combined pattern set before restoring any DEFAULT_ENV_TEMPLATE_ALLOWLIST name found in the ignored set. Reproduced exactly as described: copy_workspace(repo, sandbox, [".env.example"]) still copied .env.example into the sandbox despite the explicit --ignore.

Fix: _ignore_with_env_template_allowlist now takes default_patterns and extra_patterns separately, builds two independent shutil.ignore_patterns functions (verified its matching semantics against the stdlib source — it's called once per directory with that directory's names, matching basenames via fnmatch.filter), and only restores an allowlisted name when it was matched by the DEFAULT_IGNORE-based function and not by the extra_ignores-based function. copy_workspace now calls it as _ignore_with_env_template_allowlist(DEFAULT_IGNORE, tuple(extra_ignores)).

Regression test added: test_copy_workspace_extra_ignore_overrides_env_template_allowlist — an explicit extra_ignores=[".env.example"] now excludes it while an unrelated template (.env.sample) is still preserved. Confirmed fail-before (reverted the fix, test failed with assert not True on the exact reported symptom) / pass-after. The pre-existing test_copy_workspace_preserves_env_templates_but_excludes_env_secrets still passes unchanged.

2. "Relative PATH tools fail isolation" — CONFIRMED, fixed.

scripts/ci/sandboxed_web_e2e.py, _resolve_isolated_executable's bare-command branch resolved purely via shutil.which(argv0, path=path). Confirmed against the CPython shutil.which source: it joins every PATH entry with the command name via os.path.join, and for a relative entry that join is only ever checked against the real process's os.getcwd() — there's no parameter to redirect that. So PATH=bin:/usr/bin with bin meant to be read relative to the copied repo failed to resolve a real copied_repo/bin/tool, and isolated_command raised "executable could not be resolved" (exit 126).

Fix: added _which_relative_to_cwd, a hand-rolled PATH search (split on os.pathsep, os.environ["PATH"]/os.defpath fallback when path is None) used as a fallback when shutil.which returns None. It anchors a relative PATH entry at the command's cwd (the copied repo) instead of the wrapper's own cwd; absolute entries are used as-is, unaffected. A relative entry that, once joined with cwd, would resolve outside sandbox_root is skipped without ever touching the real filesystem — so a PATH entry like ../../.. can't be used to probe for or resolve host executables outside the sandboxed copy. _resolve_isolated_executable and isolated_command now thread sandbox_root through to support this.

Regression tests added:

  • test_isolated_command_resolves_bare_command_via_relative_path_entryPATH=bin (relative) with an executable at copied_repo/bin/tool, invoking bare tool, now resolves and produces a valid bwrap command instead of raising.
  • test_isolated_command_rejects_relative_path_entry_escaping_sandboxPATH=../../.. still fails closed with "could not be resolved" (non-regression).
  • test_which_relative_to_cwd_returns_none_for_empty_path — closes a coverage gap on the empty-PATH branch.

Confirmed fail-before (reverted the fix, the relative-PATH resolution test failed with the exact reported RuntimeError: executable could not be resolved for isolation validation: tool; the escape test already passed before, confirming no existing fail-closed behavior was weakened) / pass-after.

Process notes

  • Fetched origin/fix/sandboxed-web-e2e-isolation-clean at bd0697aa, did all work in an isolated worktree under /tmp. Before pushing, re-fetched and found the branch had moved to 528a1eae (a concurrent fix wrapping copy_workspace's ValueError in both main() functions) — no overlap with the functions touched here, merged cleanly with git merge --no-edit (no conflicts), re-ran everything on the merged tree.
  • Pushed as a merge commit, no force-push, no rebase.

Commits pushed:

  • 5b96f849 — both fixes + regression tests (on top of bd0697aa)
  • 3c32d3ca — merge of concurrent 528a1eae into the above

Validation on the final pushed tree:

  • PYTHONPATH=. python3 -m pytest tests -q1984 passed, 1 skipped, 21 subtests passed
  • coverage report scoped to scripts/ci/sandboxed_verify.py + scripts/ci/sandboxed_web_e2e.py100% statement+branch coverage (both files)
  • Full-repo scripts/ci coverage total → 100%
  • python3 -m interrogate -v100% docstring coverage, PASSED

_Generated by Claude Code


Generated by Claude Code

…-isolation-clean

# Conflicts:
#	CHANGELOG.md

Copy link
Copy Markdown
Contributor Author

Reconciled the merge conflict against main (base advanced via #1270, #1456, #1459, #1433, and others since this branch's last activity).

What conflicted: only CHANGELOG.md. Both this branch and main had independently appended new bullet entries at the top of the ## [Unreleased] section, with no shared/overlapping prose — a pure append-vs-append conflict, not a logic conflict. All other files (scripts/ci/pr_review_merge_scheduler.py, pr_review_fix_scheduler.py, contextual_orchestrator_review_sidecar.sh, workflow files, etc.) merged automatically with no markers.

How resolved: kept both sets of entries in full — this PR's isolation/SSRF-hardening changelog entries first, followed by main's already-landed entries (scheduler rate-limit backoff, Strix evidence-state fixes, draft-review-dispatch hardening, etc.) — then the pre-existing tail of the file, unchanged. No content from either side was dropped.

Verified this PR's actual code is untouched by the conflict: main never modified scripts/ci/sandboxed_web_e2e.py or tests/test_sandboxed_web_e2e.py between the merge base and current main — this PR's isolation/SSRF logic merged with zero overlap.

Merge mechanics: ordinary git merge origin/main (no rebase, no force-push), fetched the branch again immediately before pushing to confirm no concurrent push had landed, then pushed the merge commit directly.

Post-merge verification (all green):

  • PYTHONPATH=. coverage run -m pytest tests -q → 2107 passed, 1 skipped, 21 subtests passed
  • coverage report --show-missing → 100% coverage on scripts/ci (hard gate met)
  • interrogate → 100% docstring coverage (hard gate met)
  • bash -n on both shell scripts touched by the merge (contextual_orchestrator_review_sidecar.sh, test_strix_quick_gate.sh) → clean
  • No hash-pinned *-hashes.txt requirements files were part of the conflict, so no regeneration was needed

Branch pushed as a merge commit (3c32d3ca..ed05b554); PR itself was not merged.


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 4 new potential issues.

Devin Review

Comment thread scripts/ci/sandboxed_web_e2e.py
Comment thread scripts/ci/sandboxed_web_e2e.py
Comment thread scripts/ci/sandboxed_web_e2e.py
Comment on lines +317 to +324
def isolated_command(
command: str,
*,
backend: str,
cwd: Path,
sandbox_root: Path,
env: dict[str, str],
) -> str:

@devin-ai-integration devin-ai-integration Bot Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Shell syntax remains unsupported

These inputs are called shell commands, but shell=False has always treated operators and expansion as ordinary arguments. Isolation does not introduce this limitation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

coderabbitai[bot]

This comment was marked as resolved.

…-command, and workspace-path gaps

Four Devin findings on this PR's bubblewrap sandboxing:

- wait_for_url's opener now passes an explicit ProxyHandler({}) alongside
  NoRedirectHandler, so HTTP_PROXY/HTTPS_PROXY/*_proxy environment variables
  can never route a "loopback-only, isolated" readiness probe through an
  external proxy (mirrors the fix already applied to a different opener in
  materialize_base_python_requirements.py).
- New require_unoccupied_readiness_port rejects a readiness URL whose port
  already answers before this run starts its own service, called once in
  main() right before start_service. isolated_command does not create a
  network namespace for the commands it wraps (the host readiness poller and
  the E2E command both need to reach the same loopback ports), so this
  closes the "polls some other, unrelated runner service" gap without
  breaking that shared-loopback design.
- parse_args now shell-tokenizes all three of --backend-cmd/--frontend-cmd/
  --e2e-cmd up front, independent of --isolation, and rejects a blank or
  unmatched-quote command through argparse's own clean SystemExit(2) path.
  Previously, with isolation disabled, such a command bypassed
  isolated_command entirely and crashed with an uncaught ValueError deep
  inside start_service/run_shell's own shlex.split call.
- isolated_command now rewrites an absolute executable path that resolves
  inside the sandbox copy to its /workspace-relative form, and
  _sandbox_environment now does the same for PATH entries rooted under the
  sandbox copy -- bubblewrap binds the copy at /workspace, not at its
  original host path, so an absolute copied-repo launcher or PATH entry
  previously failed to launch inside the sandbox unchanged.

Also folds in a CodeRabbit finding on the same head, in sandboxed_verify.py:
_reject_escaping_symlinks walked the unresolved destination path but checked
each symlink against the resolved root, so a sandbox root reached through a
symlinked ancestor (e.g. a symlinked default temp directory) made
path.relative_to(root) raise for every symlink in an otherwise-legitimate
copy. Now walks from the already-resolved root instead; escape detection
itself is unchanged and still covered.

Plus two quick-win CodeRabbit items: documents the DEFAULT_ENV_TEMPLATE_ALLOWLIST
carve-out in the command-isolation doc, and fixes four tests that mocked
shutil.which for _probe_isolation_capability's shell selection after it was
changed to check fixed PROBE_SHELL_PATHS directly instead -- those mocks were
silent no-ops relying on whatever shell the host happened to have mounted.

All new behavior is covered by new regression tests reproducing each bug
against pre-fix code; scripts/ci stays at 100% line+branch coverage and 100%
docstring coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

Copy link
Copy Markdown
Contributor Author

Pushed 49d4d6b6 (fast-forward on top of ed05b554) fixing Devin's four findings plus one CodeRabbit finding on the same head, with a test-first fix for each. Full detail below; happy to reply on individual threads if useful.

1. 🟥 Proxy bypass on the readiness probe — fixed

wait_for_url's urllib.request.build_opener(NoRedirectHandler()) had no ProxyHandler, so urllib installed its own default one via getproxies() — meaning HTTP_PROXY/HTTPS_PROXY/http_proxy/https_proxy in the process environment could route the "loopback-only" readiness probe through an external proxy, defeating require_loopback_readiness_url's SSRF check. Grepped the whole file for every outbound-request site — this is the only one (build_opener/urlopen/Request( all appear nowhere else) — so there was no second instance to catch.

Fix: urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirectHandler()), the exact pattern already used in materialize_base_python_requirements.py.

Regression test (test_wait_for_url_ignores_proxy_environment_variables) points http_proxy/https_proxy at a definitely-closed local port and polls a real local server directly: pre-fix this times out and returns False (every attempt gets refused by the dead "proxy"); post-fix it succeeds immediately.

2. 🟨 Readiness probes reach unrelated runner services — fixed with a different mechanism than either suggested option

Confirmed real: isolated_command's bwrap invocation has no --unshare-net, so the sandboxed backend/frontend/E2E commands share the host network namespace, and require_loopback_readiness_url accepts any loopback port.

I didn't implement either literal suggested option, for reasons grounded in this PR's own doc (docs/doctoring/sandboxed-web-readiness-loopback-boundary.md + sandboxed-web-command-isolation.md):

  • Network-namespace isolation (--unshare-net) is structurally incompatible with this tool's job: the readiness poller runs unsandboxed, in this same process, and needs to reach the backend/frontend's loopback ports; the E2E command (a separate bwrap invocation) needs to reach them too. Giving each its own netns would make them unable to reach each other at all — this isn't a smaller fix, it breaks the feature. sandboxed-web-command-isolation.md already documents this as accepted: "callers that need stronger network policy must run this helper inside a network-restricted runner or container."
  • A new "expected port" CLI parameter would be a caller-facing contract change, and doesn't add real protection here anyway — the readiness URL and the backend/frontend command string already come from the exact same trust level (the CI workflow author), so there's no meaningful boundary to enforce between them.

Instead, added require_unoccupied_readiness_port(url), called once in main() immediately before either service starts: it attempts a real connection to the readiness URL's host:port, and if anything already answers there — before this run's own service could possibly be listening — it fails closed with a clear error instead of trusting it. This directly closes the exact scenario in the finding ("a port belonging to some other unrelated service already listening") without touching the network-isolation model or the CLI contract.

Regression tests: test_require_unoccupied_readiness_port_rejects_pre_existing_listener, ..._allows_a_free_port, and a main()-level test_main_reports_occupied_readiness_port_before_starting_services.

3. 🟡 Debug-mode crash on malformed commands — fixed

Confirmed: with --isolation disabled, args.backend_cmd/frontend_cmd/e2e_cmd skip isolated_command() entirely and go straight to shlex.split() inside start_service/run_shell with no except around either call — a blank string or unmatched quote raised uncaught.

Fix: parse_args now shell-tokenizes all three commands up front (_require_parseable_command), independent of --isolation, catching shlex.split's ValueError and rejecting an empty token list, both via parser.error(...) (clean SystemExit(2)). isolated_command's own check is unchanged (still unit-tested by the existing test_isolated_command_rejects_empty_command).

Note this necessarily changes one existing test: test_main_reports_coded_failure_for_whitespace_only_command previously exercised the required-isolation path reaching isolated_command's rejection (exit 126). Since parse_args now catches this earlier for both modes, that case is now caught before main()'s body ever runs (SystemExit(2)) — I updated that test to match, with a docstring explaining why, rather than leaving it asserting stale behavior.

Added test_parse_args_rejects_blank_backend_frontend_e2e_commands, test_parse_args_rejects_malformed_quoting_in_commands (all three flags, both defect types), and two main()-level disabled-isolation integration tests confirming no traceback.

4. 🟡 Absolute workspace paths don't launch — fixed

Confirmed: isolated_command validates an absolute executable path under sandbox_root correctly, but passes the host absolute path through unchanged into the constructed bwrap argv — bubblewrap binds sandbox_root at /workspace, not at its original host path, so the literal argv0 doesn't exist inside the sandbox.

Fix: isolated_command now rewrites an absolute argv0 that resolves inside sandbox_root to its /workspace-relative form. Separately, _sandbox_environment now applies the same rewrite to PATH entries rooted under sandbox_root (previously only HOME/TMPDIR/XDG_* were translated), since a bare-name command relying on PATH lookup for a workspace-local binary has the identical problem.

Tests: test_isolated_command_translates_absolute_workspace_launcher_to_sandbox_mount and test_sandbox_environment_translates_workspace_path_entries (plus a coverage-completing ..._skips_path_translation_when_path_is_absent).

5. CodeRabbit: ancestor-symlink false-positive rejection in sandboxed_verify.py — confirmed real, fixed

Reproduced independently before touching anything: _reject_escaping_symlinks computed root = destination.resolve(strict=True) but then walked destination.rglob("*") (unresolved) and checked each symlink with path.relative_to(root) (resolved). When some ancestor of the sandbox destination is itself reached through a symlink (e.g. a symlinked default temp dir — this is real on macOS, where /tmp/private/tmp, and generally wherever TMPDIR points through a symlink), destination and root are different strings for the same real location, so relative_to raised for any symlink in an otherwise entirely legitimate copy.

One-line fix: walk root.rglob("*") instead of destination.rglob("*"). Verified this doesn't weaken escape detection — added both test_copy_workspace_accepts_internal_symlink_when_sandbox_root_is_reached_via_symlinked_ancestor (was failing before the fix, confirmed by direct repro) and test_copy_workspace_still_rejects_escape_when_sandbox_root_is_reached_via_symlinked_ancestor (genuine escape still rejected in the same ancestor-symlink setup).

Two quick-win items also folded in:

  • Documented the DEFAULT_ENV_TEMPLATE_ALLOWLIST carve-out (.env.example/.env.sample/.env.template) in docs/doctoring/sandboxed-web-command-isolation.md, which previously only described the broad .env* exclusion.
  • Fixed four tests (test_probe_isolation_capability_accepts_working_bwrap, ..._rejects_denied_namespaces, ..._rejects_when_probe_cannot_run, ..._rejects_on_timeout) that mocked shutil.which to control the probe shell — _probe_isolation_capability resolves its shell via _probe_shell() against fixed PROBE_SHELL_PATHS now, not shutil.which, so those mocks were silent no-ops that happened to pass only because the CI host has a real /bin/sh. Now mock _probe_shell directly.

Verification

  • python3 -m pytest tests/test_sandboxed_web_e2e.py tests/test_repository_branch_coverage_execution_sandboxes.py tests/test_opencode_existing_approval_gate.py tests/test_opencode_security_boundaries.py -q147 passed
  • PYTHONPATH=. python3 -m pytest tests -q (full suite) → 2120 passed, 1 skipped, 21 subtests passed, no regressions
  • coverage run -m pytest tests && coverage report100% on scripts/ci (line + branch)
  • interrogate100.0% docstring coverage
  • python3 -m ruff check scripts/ci/sandboxed_web_e2e.py tests/test_sandboxed_web_e2e.py tests/test_repository_branch_coverage_execution_sandboxes.py → same 6 findings as on unmodified ed05b554 (verified via git stash), all pre-existing and unrelated to this change; no new findings
  • git diff --check → clean

Commit: 49d4d6b63c0cf78b2b4fd323025eabbc80d7e90e, pushed fast-forward onto fix/sandboxed-web-e2e-isolation-clean (confirmed landed on origin).


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +546 to +548
parsed = urllib.parse.urlparse(url)
hostname = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)

@devin-ai-integration devin-ai-integration Bot Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Port zero probes the default port

For an explicit port zero, parsed.port or ... probes port 80 or 443. An unrelated listener there causes a false pre-start failure.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…-isolation-clean

# Conflicts:
#	CHANGELOG.md
#	scripts/ci/sandboxed_web_e2e.py

Copy link
Copy Markdown
Contributor Author

Merge conflict resolved (merge commit, no rebase)

main had advanced past this branch's last main-merge (through #1394, #1453, #1442, #1462 since this branch's ed05b554 merge point). Merged origin/main into this branch with a merge commit — d0c869cc0129192137b22b2ed3e1f1419fd0adb9 (parents: 49d4d6b6 this branch, 1cf2f912 origin/main) — and pushed directly to fix/sandboxed-web-e2e-isolation-clean. No rebase, amend, or force-push.

Conflicts found (2 files — verified directly, not assumed)

1. scripts/ci/sandboxed_web_e2e.py — real code conflict, but functionally identical on both sides
Both this branch (Devin-finding fix, "close proxy bypass...") and main (#1394 SSRF fix) independently added the exact same ProxyHandler({}) protection to wait_for_url's opener, differing only in argument order:

HEAD:        build_opener(urllib.request.ProxyHandler({}), NoRedirectHandler())
origin/main: build_opener(NoRedirectHandler(), urllib.request.ProxyHandler({}))

Verified via CPython's urllib.request internals that this order difference has zero runtime effect: OpenerDirector.add_handler sorts registered handlers by each handler's fixed handler_order class attribute (ProxyHandler.handler_order = 100) regardless of the order passed to build_opener, and empirically ProxyHandler({}) (empty proxy map) registers no protocol _open methods at all, so it isn't even added to the handler chain either way. Resolved by keeping main's already-reviewed form (NoRedirectHandler(), ProxyHandler({})). All other hunks in this file (the shell=False additions, require_unoccupied_readiness_port, etc.) auto-merged cleanly with no overlap.

2. CHANGELOG.md — two independent ## [Unreleased] entries at the same insertion point
This branch's own entries (web-isolation work) and main's #1462 entry (scheduler regression fixes) were both inserted immediately after the ## [Unreleased] header, per this file's established convention (verified against the branch's own prior insertions: new entries always land at the very top of [Unreleased], pushing older entries down). Kept both, with this branch's own not-yet-merged entries on top (consistent with where they'll sit once this PR itself lands) followed by main's newer entry, then the rest of the existing list unchanged. No content dropped from either side.

Non-conflicts checked explicitly (flagged as risk areas in the task): docs/product-technical-gap-baseline.md and the two docs/doctoring/*.md files auto-merged cleanly as pure additions from both sides — no shared line/paragraph was touched by both.

Verification (all green, post-merge, before push)

  • grep -rn '<<<<<<<\|=======\|>>>>>>>' . across the full repo — clean (two unrelated pre-existing string matches in test_strix_quick_gate.sh fixture output and pr-review-autofix.yml prose, not markers).
  • PYTHONPATH=. python3 -m pytest tests -q2090 passed, 1 skipped, no regressions.
  • coverage run -m pytest tests && coverage report100% on scripts/ci (including sandboxed_web_e2e.py and sandboxed_verify.py).
  • interrogate100% docstrings, PASSED.
  • python3 -m ruff check scripts/ci/sandboxed_web_e2e.py scripts/ci/sandboxed_verify.py tests/test_sandboxed_web_e2e.py tests/test_sandboxed_verify.py — 6 pre-existing findings (import-sort/I001, SIM114, UP022 capture_output), confirmed present in the merge-base version before either side's changes (not introduced by this merge or by this PR); test files themselves are ruff-clean. Left as-is since fixing pre-existing style findings is out of scope for a conflict-resolution merge.
  • git diff --check — clean.
  • Confirmed .github/workflows/strix.yml, scripts/ci/test_strix_quick_gate.sh, scripts/ci/pr_review_merge_scheduler.py, tests/test_pr_review_merge_scheduler.py are byte-identical to origin/main post-merge (git diff origin/main -- <path> empty for all four) — this PR doesn't touch any of them.
  • git merge-base --is-ancestor origin/main HEAD succeeds; pushed head d0c869cc matches PR's reported head SHA.

No force-push, no history rewrite — this branch's prior commits and authorship are untouched.


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Local tests could not run

The review environment lacks pytest, so the focused suite could not execute. CI must provide the repository’s required coverage confirmation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +523 to +557
def require_unoccupied_readiness_port(url: str) -> None:
"""Reject a readiness URL whose port already answers before this run starts a service.

``require_loopback_readiness_url`` only proves the URL targets loopback;
it says nothing about *which* process on loopback will eventually answer
it. ``isolated_command`` does not create a network namespace for the
commands it wraps -- the backend, frontend, and E2E command all still
need to reach the same host loopback interface the readiness poller
itself uses (the poller runs unsandboxed, in this process), so giving the
sandboxed commands a private network namespace is not an available
option here without breaking that readiness/E2E flow. On a shared
loopback interface, an operator- or config-supplied readiness URL that
happens to name a port some other, unrelated process on the CI runner
already occupies would otherwise be polled exactly like the real target:
a response from that unrelated process reads as this run's service being
ready, and any later request the E2E command makes to the same address
reaches it too, whether or not it was ever meant to be reachable this
way. Calling this once, immediately after ``require_loopback_readiness_url``
and before ``start_service`` starts anything, ensures a port that
answers now can only be attributed to some other process -- this run's
own service cannot yet be listening -- so it is rejected here rather
than trusted. A connection refusal or timeout means nothing is listening
yet, which is the expected, accepted state before the service starts.
"""
parsed = urllib.parse.urlparse(url)
hostname = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
try:
with socket.create_connection((hostname, port), timeout=0.2):
pass
except OSError:
return
raise ValueError(
f"readiness port is already in use by another process before this run started its service: {url}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Port probe race fails closed

Another process can claim a port after require_unoccupied_readiness_port returns. Service binding or readiness then fails, so successful evidence is not misattributed.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@seonghobae
seonghobae merged commit 046bc2b into main Aug 31, 2026
53 of 54 checks passed
@seonghobae
seonghobae deleted the fix/sandboxed-web-e2e-isolation-clean branch August 31, 2026 02:08
seonghobae added a commit that referenced this pull request Aug 31, 2026
…#1467)

* fix(sandboxed-web-e2e): probe explicit port 0, not the scheme default

require_unoccupied_readiness_port() derived the probe port with
`parsed.port or (443 if https else 80)`. urllib.parse's .port returns
the int 0 for a URL with an explicit :0 port, and `0 or X` evaluates
to X in Python, so an explicitly-requested port 0 was silently
replaced with the scheme's default port (80/443) instead of actually
being probed. Devin's review on PR #1347 flagged this pattern but it
was out of scope for that PR's authorized task.

Switch to an explicit None check so port 0 is honored, and add a
regression test that monkeypatches socket.create_connection to record
the probed address and assert it names port 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

* fix(security): scope Bandit tmpfs waivers to isolation targets

Bandit B108 correctly began running after the central file-detection repair and identified the two literal /tmp arguments passed to bubblewrap. These are tmpfs mount targets inside a new isolated namespace, not host temporary-file paths. Add B108-only waivers with adjacent rationale and a regression that pins both the scope and count, preserving every other Bandit rule and host-path finding.

* fix(lint): pair Ruff S108 waivers with the existing Bandit B108 ones

CodeRabbit ran `ruff check --select S108` against this file and found
the two bubblewrap tmpfs `"/tmp"` mount-target arguments still fail
Ruff's own insecure-temp-path rule -- `# nosec B108` only silences
Bandit, not Ruff. Add `# noqa: S108` alongside each existing waiver and
extend the regression test that already pins the Bandit waiver's exact
text/count/rationale to also pin the Ruff waiver, so this scoped
exception can't silently broaden to other paths or rules.

Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants