Skip to content

feat(ci): add a bounded subprocess primitive - #1280

Open
seonghobae wants to merge 16 commits into
mainfrom
codex/pr931-bounded-subprocess-core-20260824
Open

feat(ci): add a bounded subprocess primitive#1280
seonghobae wants to merge 16 commits into
mainfrom
codex/pr931-bounded-subprocess-core-20260824

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Outcome

First clean current-main layer replacing the monolithic #931.

  • continuously drains stdout and stderr into fixed-size final-suffix buffers
  • isolates commands in a POSIX process group and kills on overflow or timeout
  • kills the group again after the direct child exits so same-group descendants cannot retain inherited pipes
  • bounds every reader join and preserves sibling finalization after an error
  • keeps decoded UTF-8 evidence inside the declared byte budget even when replacement characters expand
  • provides bounded file-suffix reads without changing repository artifact semantics

This layer intentionally does not change sandboxed_verify, sandboxed_web_e2e, result schemas, workspace symlink policy, or the pip-audit lock. Those consumers remain separate stack layers.

Evidence

  • TDD reproduced both delayed escaped-descendant sentinel execution and UTF-8 replacement expansion before the fix
  • 25 focused tests passed; bounded_subprocess has 226 statements and 52 branches at 100%
  • full suite: 1,422 passed, 1 skipped, 16 subtests passed
  • full production total: 8,572 statements and 3,266 branches at 100%
  • scripts/ci docstrings: 100%
  • compileall and git diff --check: pass
  • APA 7th doctoring cites CWE-770 v4.20, Python 3.14.6 subprocess guidance, NIST SP 800-218, and POSIX.1-2024 process-group definitions

Part 1 of the clean replacement for #931.


Open in Devin Review

Summary by CodeRabbit

  • 새로운 기능

    • 검증 명령과 웹 E2E 실행에 출력 크기 제한 및 UTF-8 로그 처리를 추가했습니다.
    • 타임아웃이나 출력 초과 시 프로세스와 하위 프로세스를 정리합니다.
    • 실행 결과에 출력 제한, 캡처 실패, 경로 경계 거부 상태를 표시합니다.
    • 서비스 readiness 초과 시 서비스를 중지하고 제한된 로그 증거를 제공합니다.
  • 버그 수정

    • 외부 심볼릭 링크, 잘못된 URL, 누락되거나 실행 불가능한 명령을 안전하게 처리합니다.
    • 오류 발생 시 민감한 호스트 경로와 상세 트레이스백 노출을 방지합니다.
  • 문서

    • 출력 리소스 제한 및 심볼릭 링크 검증 정책을 문서화했습니다.

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 4202b85e-8e27-4428-bea9-2ec5544117a4

📥 Commits

Reviewing files that changed from the base of the PR and between 5fed0e1 and c0dd6da.

📒 Files selected for processing (1)
  • tests/test_sandboxed_web_e2e_output_limits.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

POSIX bounded subprocess 경계를 추가했다. 검증과 웹 E2E 실행은 출력 한도, 프로세스 그룹 정리, UTF-8 suffix, 심볼릭 링크 경계, 실행 오류 분류를 사용한다. 결과 필드와 회귀 테스트도 갱신했다.

Changes

샌드박스 실행 경계

Layer / File(s) Summary
bounded subprocess 계층
scripts/ci/bounded_subprocess.py, tests/test_bounded_subprocess*.py, docs/doctoring/sandboxed-output-resource-bounds.md
stdout/stderr를 제한된 후행 버퍼로 수집한다. 출력 초과와 timeout에서 프로세스 그룹을 종료한다. UTF-8 디코딩, reader 오류, 캡처 초기화와 정리 실패를 처리한다.
sandboxed_verify 경계와 결과 처리
scripts/ci/sandboxed_verify.py, tests/test_sandboxed_verify*.py, docs/doctoring/sandboxed-verification-symlink-boundary.md
검증 명령을 bounded subprocess로 실행한다. 외부 심볼릭 링크를 거부한다. 명령 누락, 실행 불가, 출력 제한, timeout, 비HTTP readiness URL을 별도 결과로 기록한다.
웹 E2E 서비스 실행과 readiness
scripts/ci/sandboxed_web_e2e.py, tests/test_sandboxed_web_e2e*.py, tests/test_sandboxed_entrypoint_and_cleanup_coverage.py, tests/test_sandboxed_service_capture_startup.py
서비스 로그와 E2E 출력을 제한한다. 비HTTP readiness URL과 실행 파일 오류를 사전에 분류한다. 서비스 로그 초과와 capture 정리 실패를 결과에 기록한다.

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: ⚪ Minimal · up to c0dd6

This change ensures the service-startup failure path is tested without requiring the isolation backend. No merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant sandboxed_verify
  participant RepositoryCopy
  participant bounded_subprocess
  participant CommandProcess
  sandboxed_verify->>RepositoryCopy: workspace 복사 및 symlink 경계 검사
  sandboxed_verify->>bounded_subprocess: bounded 검증 명령 실행
  bounded_subprocess-->>sandboxed_verify: 제한된 출력과 종료 상태 반환
  sandboxed_verify->>CommandProcess: 오류를 종료 코드와 결과 필드로 분류
Loading
sequenceDiagram
  participant sandboxed_web_e2e
  participant BackendService
  participant FrontendService
  participant bounded_subprocess
  sandboxed_web_e2e->>BackendService: bounded 서비스 시작
  sandboxed_web_e2e->>FrontendService: bounded 서비스 시작
  sandboxed_web_e2e->>BackendService: HTTP readiness 확인
  sandboxed_web_e2e->>FrontendService: HTTP readiness 확인
  sandboxed_web_e2e->>bounded_subprocess: bounded E2E 명령 실행
  bounded_subprocess-->>sandboxed_web_e2e: 제한된 출력과 종료 상태 반환
  sandboxed_web_e2e->>BackendService: 서비스 종료 및 capture 정리
  sandboxed_web_e2e->>FrontendService: 서비스 종료 및 capture 정리
Loading

Possibly related PRs

🚥 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 제목은 bounded subprocess primitive 추가라는 변경의 핵심을 정확하고 간결하게 설명합니다. CI 범위와 기능도 명확합니다.
Docstring Coverage ✅ Passed Docstring coverage is 84.31% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 255 functions across 15 files.
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.
✨ 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 codex/pr931-bounded-subprocess-core-20260824

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.

devin-ai-integration[bot]

This comment was marked as resolved.

@opencode-agent opencode-agent 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.

Pull request overview

OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed.

Findings

1. HIGH Current-head GitHub Checks - Fix failed required checks before approval

  • Problem: Failed same-head checks remain for 88f5fcc62671ca6e635be05a9ab583adf7399c7a.
  • Root cause: The model-unavailable evidence fallback is allowed only when peer GitHub Checks are complete and clean.
  • Fix: Read and fix the failed check logs below, then rerun the current-head checks.
  • Regression test: Keep the model-unavailable fallback gated on an empty failed-check rollup.

Failed checks:

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Changed file: CHANGELOG.md"]
  S1 --> I1["repository behavior"]
  I1 --> R1["Review risk: Changed file: CHANGELOG.md"]
  R1 --> V1["required checks"]
  Evidence --> S2["Docs: sandboxed-output-resource-bounds.md"]
  S2 --> I2["operator or user guidance"]
  I2 --> R2["Review risk: Docs: sandboxed-output-resource-bounds.md"]
  R2 --> V2["docs review"]
  Evidence --> S3["CI script: bounded_subprocess.py"]
  S3 --> I3["review and security gate shell path"]
  I3 --> R3["Review risk: CI script: bounded_subprocess.py"]
  R3 --> V3["bash -n plus Strix self-test"]
  Evidence --> S4["Test (3 files)"]
  S4 --> I4["regression suite"]
  I4 --> R4["Review risk: Test (3 files)"]
  R4 --> V4["targeted test run"]
Loading

@opencode-agent

opencode-agent Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

OpenCode Review Overview

  • Head SHA: 70ad61fd3e1f8aac64497bc6776f6a736de11ca6
  • Workflow run: 32746130080
  • Workflow attempt: 1
  • Gate result: REQUEST_CHANGES (approval step)

Pull request overview

OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed.

Findings

1. HIGH Current-head GitHub Checks - Fix failed required checks before approval

  • Problem: Failed same-head checks remain for 70ad61fd3e1f8aac64497bc6776f6a736de11ca6.
  • Root cause: The model-unavailable evidence fallback is allowed only when peer GitHub Checks are complete and clean.
  • Fix: Read and fix the failed check logs below, then rerun the current-head checks.
  • Regression test: Keep the model-unavailable fallback gated on an empty failed-check rollup.

Failed checks:

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Changed file: CHANGELOG.md"]
  S1 --> I1["repository behavior"]
  I1 --> R1["Review risk: Changed file: CHANGELOG.md"]
  R1 --> V1["required checks"]
  Evidence --> S2["Docs (2 files)"]
  S2 --> I2["operator or user guidance"]
  I2 --> R2["Review risk: Docs (2 files)"]
  R2 --> V2["docs review"]
  Evidence --> S3["CI script (3 files)"]
  S3 --> I3["review and security gate shell path"]
  I3 --> R3["Review risk: CI script (3 files)"]
  R3 --> V3["bash -n plus Strix self-test"]
  Evidence --> S4["Test (12 files)"]
  S4 --> I4["regression suite"]
  I4 --> R4["Review risk: Test (12 files)"]
  R4 --> V4["targeted test run"]
Loading

devin-ai-integration[bot]

This comment was marked as resolved.

@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 03:19
@seonghobae
seonghobae enabled auto-merge (squash) August 24, 2026 03:33
@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head local evidence for e587c05627a7fb2b45f65375c0c1bc752eb9bde2 after merging main@0c6b9a6459c9dbdf5e23fb01df7a32a8a14964b3: 1,480 tests passed, 1 skipped, 16 subtests; statement and branch coverage are 100%; production docstrings are 100%; ruff, compileall, and git diff --check passed. Hosted required checks and a fresh exact-head review remain pending; no predecessor approval is reused.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent review | Independent exact-current-head formal review requested for e587c05627a7fb2b45f65375c0c1bc752eb9bde2. Read the full current diff and current-head checks only; do not reuse predecessor approvals or hosted evidence. Report changed-file walkthrough, security/control-plane impact, test gaps, residual uncertainty, and a structured verdict.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head RCA for e587c05: Strix run 32691970400 failed closed after repeated NVIDIA NIM HTTP 429 responses, then the distinct fallback returned HTTP 404; no vulnerability report artifact was produced. This is provider/backend infrastructure evidence, not a source finding in the bounded subprocess change. Keep the fail-closed gate and rerun the failed Strix job normally.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Current-head re-review request\n\nCurrent exact head: e587c05\n\nThe previous REQUEST_CHANGES review is tied to an older Strix/provider-failure head. Current-head evidence must be re-established from this SHA; predecessor failures and approvals are not being reused. Please inspect the complete current diff and publish a substantive review after the current required Checks finish. No merge is requested until exact-head checks and independent approval are current.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head verification receipt

  • Exact head: e587c05627a7fb2b45f65375c0c1bc752eb9bde2
  • Exact base: 0c6b9a6459c9dbdf5e23fb01df7a32a8a14964b3
  • Verified merge-result tree: 2df7dde15c121e3f6c5c77867c0d3249edb5991e (equal to exact head tree)
  • Focused bounded-subprocess contract: 25 passed
  • Full suite: 1480 passed, 1 skipped, 16 subtests
  • Production statement and branch coverage: 100%
  • Public docstrings: 100%
  • Ruff, Python compile, diff check, and interrogate: passed
  • Hosted current evidence: CodeQL, coverage, dependency, OSV, Noema, scheduler, and Trivy checks passed; Strix failure is the known provider-only NIM/OpenAI fallback failure, and current OpenCode review remains pending.
  • Decision: WAIT_AND_REMEDIATE until current-head review/Strix gates are terminal; normal auto-merge remains enabled.

No source, workflow, ruleset, protected-branch, or bypass mutation was used.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head formal review request for e587c05. Please inspect the complete changed-file diff, with emphasis on bounded subprocess behavior, timeout/cancellation, and authority boundary. Publish a substantive Reviews API verdict tied only to this exact SHA, including security/control-plane impact, test gaps, and residual uncertainty. Do not reuse predecessor-head evidence or failed provider checks as source findings. @opencode-agent

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head e587c05627a7fb2b45f65375c0c1bc752eb9bde2 contains the previously reviewed bounded-output and reader-error fixes. The remaining terminal Strix failure is being rerun normally; no source finding is established from the provider gate. Current-head required Checks and an independent substantive approval remain required before merge.

* fix(sandbox): bound verification evidence

* fix(sandbox): classify missing verification executable

* fix(sandbox): classify non-executable commands
* fix(sandbox): bound verification evidence

* fix(sandbox): bound web E2E evidence

* fix(sandbox): classify missing verification executable

* fix(sandbox): classify missing web executables

* fix(sandbox): classify non-executable commands

* fix(sandbox): reject invalid readiness URL schemes
@seonghobae
seonghobae force-pushed the codex/pr931-bounded-subprocess-core-20260824 branch from e587c05 to 70ad61f Compare August 24, 2026 11:24
devin-ai-integration[bot]

This comment was marked as resolved.

@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 13:22
…rsing

The previous empty-command validation called shlex.split(command)
directly, which raises an uncaught ValueError on unmatched quoting and
leaked a traceback instead of the established clean parser.error path.

Copy link
Copy Markdown
Contributor Author

One more small finding on my own just-pushed fix: the empty-command validation called shlex.split(command) directly, which raises an uncaught ValueError on malformed quoting (e.g. an unterminated "), leaking a traceback instead of the established clean parser.error path. Applied Devin's suggested fix (38374193): catch the ValueError and route it through parser.error, plus a regression test per command option.

Validation: full suite PYTHONPATH=. python3 -m pytest tests -q → 1995 passed, 1 skipped, 21 subtests. coverage report --include=scripts/ci/sandboxed_web_e2e.py → 100%. interrogate -v scripts/ci/sandboxed_web_e2e.py → 100%.


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 +134 to +139
try:
tokens = shlex.split(command)
except ValueError as error:
parser.error(f"{option} is invalid: {error}")
if not tokens:
parser.error(f"{option} must not be empty")

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: Validation matches command execution

parse_args uses the same tokenizer as execution. Malformed quoting now follows the argument-error contract instead of escaping with a traceback.

Devin Review

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

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

Devin flagged that _reject_escaping_symlinks's Path.resolve(strict=True)
rejected any unresolvable symlink uniformly, including a plain dangling
target whose own file was legitimately excluded from the copy by
DEFAULT_IGNORE/extra_ignores (or is simply broken) -- aborting an
otherwise-valid verification run over a link that was never actually a
host-escape attempt.

Replaced the resolve()-based check with a hop-by-hop lexical walk
(os.readlink + os.path.normpath, tracked via a visited set), matching the
existing design in the parallel PR #1280's sandboxed_verify.py. This never
requires a target to exist, so a dangling-but-contained symlink is now
accepted while an actual escape (absolute target, or a normalized target
outside the sandbox root) or an unresolvable cycle (revisiting an
already-followed path) still raises the same ValueError as before. The hop
count is bounded so a chain that never repeats due to purely lexical
normalization still fails closed instead of walking forever.

Added regression tests for: a dangling target with no cycle, a target
excluded from the copy by DEFAULT_IGNORE, and a chain exceeding the hop
limit without ever cycling or escaping.
validate_repository_symlinks only ran _validate_contained_symlink_cycle's
absolute-hop/lexical-escape/hop-limit checks as a Path.resolve()-triggered
RuntimeError fallback. That fallback never fires: resolve() only reports
absoluteness for a chain's first hop (silently following any absolute hop
reached partway through), and its behavior on a genuine cycle is not
reliable evidence either way -- it raises RuntimeError on Python < 3.13 but
silently returns a partially-resolved path, with no error at all, starting
in 3.13 (confirmed directly: 3.11/3.12 raise, 3.13 does not). The hosted
CI gate runs Python 3.14, so a fully-contained two-node symlink cycle
already returned a resolved path there without ever exercising the manual
walk, leaving _validate_contained_symlink_cycle's cycle-return branch and
the fallback's own call site permanently uncovered (Python 3.14 full
quality gate, job 99271248147: sandboxed_verify.py at 98%, missing 191 and
239-241) -- and, more importantly, meant its absolute-hop and lexical-escape
protections for hops after the first were never actually reachable from the
real copy_workspace entry point on any Python version, only from the direct
unit tests that call the helper itself.

Replaced the resolve()-based single-hop check with an unconditional call to
_validate_contained_symlink_cycle for every symlink found. It already
implements the full walk (absolute-hop rejection, lexical-escape rejection,
cycle-acceptance, hop-limit) purely via os.readlink + os.path.normpath,
without touching Path.resolve() at all, so behavior is now deterministic
across Python versions and every hop of a chain is checked, not just the
first. All existing symlink-boundary tests (escape, absolute, internal,
contained-cycle, hop-limit) pass unchanged since this is a strict superset
of the prior single-hop resolve() check.

Verified: full suite 1995 passed, 1 skipped, 21 subtests passed;
sandboxed_verify.py at 100% statement/branch coverage (was 98%) and 100%
docstrings, reproduced on Python 3.11 (this sandbox) after confirming the
resolve() cycle-detection version split directly (3.11/3.12 raise
RuntimeError, 3.13 does not).

Copy link
Copy Markdown
Contributor Author

Fixed the "Python 3.14 full quality gate" failure (job 99271248147, head 38374193): scripts/ci/sandboxed_verify.py was at 98% coverage, missing lines 191 and 239-241.

Root cause, confirmed directly rather than guessed: validate_repository_symlinks only invoked _validate_contained_symlink_cycle's manual hop-walk (absolute-hop rejection, lexical-escape rejection, cycle acceptance, hop-limit) as a fallback triggered by Path.resolve(strict=False) raising RuntimeError on a cycle. That fallback is not reliable evidence of a cycle across Python versions — I tested directly: Python 3.11 and 3.12 raise RuntimeError for a two-node symlink cycle's resolve(), but 3.13 does not (it silently returns a partially-resolved path). CI runs Python 3.14.7, so the fallback branch — and therefore _validate_contained_symlink_cycle's cycle-return line — was never reached in the hosted run, even though a dedicated test suite (test_sandboxed_verify_symlink_boundary.py) exercises it directly as a unit, and even though copy_workspace's own contained-cycle integration test still happened to pass (the cycle's own resolved path, whatever resolve() returns for it, still lands inside source_root, so the outer relative_to check passes by coincidence — just not via the intended code path).

Beyond the coverage number, this also meant _validate_contained_symlink_cycle's absolute-hop and lexical-escape checks for any hop after the first were unreachable from the real copy_workspace entry point on any Python version — resolve() only reports absoluteness for the first hop, silently following an absolute hop reached partway through a chain during its own internal resolution.

Fix (da0532df): validate_repository_symlinks now calls _validate_contained_symlink_cycle unconditionally for every symlink, removing the resolve()-based single-hop check entirely. The manual walk (os.readlink + os.path.normpath, tracked via a visited set) never touches Path.resolve(), so behavior is deterministic across Python versions and every hop is checked, not just the first. All existing symlink-boundary tests (escape, absolute, internal, contained-cycle, hop-limit) pass unchanged since this is a strict superset of the prior check.

Verified: full suite 1995 passed, 1 skipped, 21 subtests passed; scripts/ci/sandboxed_verify.py at 100% statement/branch coverage (confirmed reproducible on this sandbox's Python 3.11 after independently confirming the version split above) and 100% docstrings.


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 2 new potential issues.

Devin Review

Comment on lines +239 to +240
if candidate.is_symlink():
_validate_contained_symlink_cycle(candidate, source_root)

@devin-ai-integration devin-ai-integration Bot Aug 30, 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: Intermediate absolute links remain covered

os.walk validates each copied symlink independently. An absolute intermediate link therefore reaches _validate_contained_symlink_cycle even when another chain stops before it.

Devin Review

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

Comment thread scripts/ci/sandboxed_verify.py
The same off-by-one Devin flagged on the parallel PR #1347 (which shares
this file's identical hop-walk design) applies here too, and my prior
commit (da0532d, routing every symlink through this walk unconditionally
instead of only on a resolve()-triggered fallback) made it newly reachable
in production rather than just from direct unit tests: a chain of exactly
MAXIMUM_SYMLINK_HOPS (40) real, OS-resolvable symlinks was incorrectly
rejected. Each loop iteration checks one position and, only if it is a
symlink, advances to the next -- so resolving N real hops needs N+1 checks
(N to walk them, one more to confirm the final landing position is not
itself a further symlink). range(MAXIMUM_SYMLINK_HOPS) only gave N checks.
Reproduced directly: a 40-real-symlink chain terminating in a real file
raised "exceeds the supported hop limit" before the fix, copies cleanly
after.

Fix: range(MAXIMUM_SYMLINK_HOPS + 1). Verified this doesn't change any
existing test's outcome, including test_validate_contained_symlink_cycle_fails_closed_past_hop_limit
(a 3-node cycle with the hop limit monkeypatched to 2 still exceeds the new,
also-monkeypatched-relative budget) and the cycle-acceptance tests (a real
cycle is caught well within budget regardless of the +1). Added a boundary
regression test for exactly-N-hops success.

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

Copy link
Copy Markdown
Contributor Author

Proactive fix, not from a bot finding on this PR: Devin flagged an off-by-one in the identical hop-walk design on the parallel PR #1347 ("Forty-link chains fail verification") — a chain of exactly MAXIMUM_SYMLINK_HOPS (40) real, OS-resolvable symlinks was incorrectly rejected, since each loop iteration checks one position and only advances past it if it's itself a symlink, so N real hops need N+1 checks, not N.

My prior commit here (da0532df, routing every symlink through _validate_contained_symlink_cycle unconditionally instead of only via a resolve()-triggered fallback that almost never fired) made this exact same off-by-one newly reachable in production on this file too — previously it was latent, exercised only by the direct unit tests that call the helper itself.

Fixed in f062252d: range(MAXIMUM_SYMLINK_HOPS + 1). Verified this doesn't change any existing test's outcome (test_validate_contained_symlink_cycle_fails_closed_past_hop_limit's 3-node-cycle-with-limit-2 case still exceeds the new relative budget; real cycles are still caught well within budget). Added test_validate_contained_symlink_cycle_accepts_a_chain_of_exactly_the_hop_limit to lock in the boundary.

Verified: full suite 1996 passed, 1 skipped, 21 subtests passed; 100% statement/branch coverage and docstrings on scripts/ci/sandboxed_verify.py.


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

seonghobae pushed a commit that referenced this pull request Aug 30, 2026
Devin flagged "nested directory links escape sandbox" on the parallel PR
#1280, which shares this file's hop-walk design: a symlink target that
itself contains an intermediate component which is a symlink (e.g.
"some-alias/../secret") defeats a check that collapses the whole target
string in one os.path.normpath call, because normpath cancels "some-alias"
against the following ".." purely textually without ever re-examining
whether some-alias is itself a symlink needing its own resolution first.

Reproduced directly against this file (not just #1280's): a self-alias
symlink pointing at "." (its own parent, the repo root -- entirely
legitimate and safe standing on its own) combined with a second symlink
whose target is "self-alias/../outside-secret.txt" was NOT caught -- the
whole string collapsed lexically to "outside-secret.txt" (looking safe),
while resolving it for real, one component at a time, correctly shows that
following self-alias lands at the repo root itself (zero depth), so the
very next ".." immediately exits it.

Fix: replaced the whole-target os.path.normpath collapse with a
component-by-component walk that re-checks is_symlink() after every single
path segment, substituting a symlink's own target components back onto the
work queue instead of treating the whole original target string as one
atomic lexical unit. This also fixes the hop-limit off-by-one from
fe237c4 as a natural consequence: 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 special-casing.

Verified against all existing symlink tests unchanged (escape, absolute,
internal, dangling, excluded-by-DEFAULT_IGNORE, cycle, hop-limit boundary)
plus two new ones: the nested-alias escape (must now raise) and a
legitimate cross-directory ".." traversal that stays in-bounds (must still
be accepted, for branch coverage on the successful ".." path).

Full suite: 1969 passed, 1 skipped, 21 subtests passed; sandboxed_verify.py
at 100% statement/branch coverage and 100% docstrings.
… 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.

Copy link
Copy Markdown
Contributor Author

Two fixes in 39a572c5, both stemming from the same root design issue as the identical hop-walk in the parallel PR #1347:

1. The nested-alias escape fix from an earlier pass on this branch was lost — I'd written and verified it in a worktree but the worktree got cleaned up before I pushed it, amid a burst of notification triage. Redone here: reproduced directly (self-alias -> ".", combined with a second symlink targeting "self-alias/../outside-secret.txt") was not caught before this fix — 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. Devin's "Valid symlink paths are rejected" finding on #1347 (same hop-walk design) also applies here, in a 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 accept-on-revisit branch by coincidence — for the wrong reason, and silently, without validating whatever came after it in the chain. A genuine cycle has no well-defined resolved position to hand back to a caller that needs to keep resolving further components past it, so I'm changing this file's cycle policy from "accept if contained" to "reject," matching #1347's simpler, already-established, more conservative behavior.

Fix: rewrote _validate_contained_symlink_cycle as a thin entry point over a new recursive _resolve_repository_symlink_components. A symlink is added to an active set only while its own target is being resolved and removed once that recursive call returns successfully — so the same symlink referenced twice non-recursively is accepted, while a genuine self-referential cycle raises. Updated the two tests that asserted cycle-acceptance to assert rejection instead, 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; scripts/ci/sandboxed_verify.py at 100% statement/branch coverage and 100% docstrings.


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 2 new potential issues.

Devin Review

Comment thread scripts/ci/sandboxed_web_e2e.py Outdated
Comment on lines +231 to +263
for component in parts:
if component == "..":
if resolved == source_root:
raise RepositoryPathBoundaryError(
f"symlink escapes repository verification sandbox: {candidate}"
)
resolved = resolved.parent
continue
step = resolved / component
if not step.is_symlink():
resolved = step
continue
if step in active:
raise RepositoryPathBoundaryError(
f"symlink chain could not be resolved: {candidate}"
)
if hops_remaining[0] <= 0:
raise RepositoryPathBoundaryError(
f"symlink chain exceeds the supported hop limit: {candidate}"
)
active.add(step)
hops_remaining[0] -= 1
target = Path(os.readlink(step))
if target.is_absolute():
raise RepositoryPathBoundaryError(
f"symlink escapes repository verification sandbox via absolute target: "
f"{step} -> {target}"
)
resolved = _resolve_repository_symlink_components(
target.parts, resolved, source_root, active, hops_remaining, candidate
)
active.discard(step)
return resolved

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: Intermediate symlinks resolve before traversal

The walker resolves each alias before later .. components. Active-chain tracking rejects recursive cycles without rejecting completed repeated references.

Devin Review

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

…killpg race

Devin flagged "Cleanup race leaves service zombies": stop_service's
poll() is None check only proves the leader was alive at that instant --
if it exits before os.killpg runs, the whole process group is already
gone and killpg raises ProcessLookupError. The exception handler swallowed
that with a bare `pass`, never calling wait() on a leader that is a
genuine zombie at that point (exited, but not yet reaped by this parent),
leaving it unreaped until the wrapper process itself exits.

Fix: the ProcessLookupError handler now calls service.process.wait(timeout=10)
to reap it, matching Devin's suggested fix exactly.

Added a regression test simulating the race (killpg always raises
ProcessLookupError against a still-poll()-alive process) and asserting
wait() is called; verified it fails against the pre-fix bare `pass` and
passes against the fix.

Full suite: 1998 passed, 1 skipped, 21 subtests passed; sandboxed_web_e2e.py
at 100% statement/branch coverage and 100% docstrings.

Copy link
Copy Markdown
Contributor Author

"Cleanup race leaves service zombies" — confirmed and fixed in 2dccde4f.

stop_service's poll() is None check only proves the leader was alive at that instant; if it exits before os.killpg runs, the whole process group is already gone and killpg raises ProcessLookupError. The exception handler swallowed that with a bare pass, never calling wait() on a leader that is a genuine zombie at that point — exited, but not yet reaped by this parent — leaving it unreaped until the wrapper process itself exits.

Fix: the ProcessLookupError handler now calls service.process.wait(timeout=10) to reap it, matching the suggested fix exactly.

Added a regression test simulating the race (killpg always raises ProcessLookupError against a still-poll()-alive process) and asserting wait() is called; verified it fails against the pre-fix bare pass and passes against the fix.

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


_Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

opencode-review failure on 2dccde4f (job 99279445535) is the standard fail-closed wait-state, not a defect: the async OpenCode dispatch hasn't posted a verdict for this exact head yet. No action needed beyond waiting for the next dispatch cycle.


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 1 new potential issue.

Devin Review

Comment on lines 349 to +350
except ProcessLookupError:
return
service.process.wait(timeout=10)


def tail_text(path: Path, max_lines: int = 80) -> str:
"""Return the final lines of a service log."""
service.process.wait(timeout=10)

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: Leader-exit race remains bounded

When killpg loses the exit race, wait(timeout=10) reaps the leader. Its timeout still enters the bounded fallback cleanup.

Devin Review

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

seonghobae added a commit that referenced this pull request Aug 31, 2026
* fix(security): require OS isolation for web e2e commands

* fix(security): harden loopback and document isolation

* fix(security): mount isolated root filesystem

* docs(security): document isolated web verification

* fix(e2e): cover and harden isolated command paths

* docs: make sandbox changelog actionable

* fix(e2e): fail closed on rejected sandbox commands

* fix(e2e): validate readiness before launch

* fix(e2e): back off after server readiness errors

* fix(e2e): validate readiness ports and fail closed on isolation gaps

Addresses three live Devin Review findings on PR #1347:

- require_loopback_readiness_url never read the parsed port, so a
  nonnumeric or out-of-range port (e.g. "http://127.0.0.1:abc/health")
  reached urllib.request.urlopen and raised an uncaught
  http.client.InvalidURL instead of the documented exit code 125.
  Now validated up front and converted to ValueError.
- isolated_command silently skipped the read-only-root validation
  whenever shutil.which could not resolve the executable, letting an
  unvalidated command through. It now fails closed the same way an
  out-of-bind-root executable already does.
- isolation_backend accepted any bwrap binary discovered on PATH
  without proving it can actually create the namespaces bubblewrap
  needs. A bounded capability preflight (_probe_isolation_capability)
  now exercises the same essential namespace/mount operations
  isolated_command depends on and classifies a denied host as
  unavailable isolation (exit 126) instead of a confusing later
  failure.

The "workspace symlinks escape filesystem isolation" finding was
investigated and found not reproducible: isolated_command's
--tmpfs / root replacement means any bind not explicitly listed
(read-only roots, /etc identity files, /workspace) simply does not
exist inside the sandbox, so a symlink to an unbound host path dangles
(ENOENT) rather than resolving. Verified empirically with a real bwrap
build against the actual copy_workspace/isolated_command code path.

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

* Merge remote-tracking branch 'origin/fix/sandboxed-web-e2e-isolation-clean' into fix/sandboxed-web-e2e-isolation-clean

# Conflicts:
#	docs/doctoring/sandboxed-web-command-isolation.md
#	scripts/ci/sandboxed_web_e2e.py
#	tests/test_sandboxed_web_e2e.py

* fix(sandboxed-verify): use strict resolve() for symlink escape check

path.resolve() with the default strict=False does not reliably raise
for a symlink cycle across Python versions — some CPython releases
detect the cycle and silently return a partially-resolved path instead
of raising RuntimeError/OSError. This let a genuine a->b->a symlink
cycle slip past _reject_escaping_symlinks undetected on CI's Python,
failing test_copy_workspace_rejects_unresolvable_symlink_cycle with
"DID NOT RAISE ValueError".

resolve(strict=True) requires the fully-resolved path to actually
exist, so both a cycle and a plain dangling target reliably raise
OSError (ELOOP/ENOENT), which the existing except clause already
converts to the intended ValueError.

* fix(e2e): mirror full probe operations, exclude credential paths from sandbox copy

Addresses the two remaining live Devin Review findings on PR #1347:

- _probe_isolation_capability now mirrors every operation
  isolated_command actually performs (--new-session, /tmp tmpfs, and a
  writable bind+chdir into the same mount point real commands use)
  against a real throwaway temp directory, instead of a reduced probe
  that could pass on a host denying one of those specific operations
  and only fail later once a real service starts.
- copy_workspace's default ignore list now excludes common
  credential-bearing dotfiles/dirs (.env*, .netrc, .npmrc, .pypirc,
  .pgpass, .git-credentials, .ssh, .gnupg, .aws, .kube, .docker) so a
  repo checkout that happens to carry one of these never rides into
  the sandboxed command's writable, readable /workspace mount. Logs
  and per-command scrubbed homes stay in that same mount deliberately
  (the tested command needs to write them) -- this narrows what's
  copied in, it does not split the mount by service.

Verification: full suite 1930 passed/1 skipped/21 subtests, coverage
100% (sandboxed_verify.py 120/120, sandboxed_web_e2e.py 282/282),
interrogate 100%, ruff clean.

* fix(sandboxed-verify): use lexical symlink walk, not resolve(), to reject escapes

Devin flagged that _reject_escaping_symlinks's Path.resolve(strict=True)
rejected any unresolvable symlink uniformly, including a plain dangling
target whose own file was legitimately excluded from the copy by
DEFAULT_IGNORE/extra_ignores (or is simply broken) -- aborting an
otherwise-valid verification run over a link that was never actually a
host-escape attempt.

Replaced the resolve()-based check with a hop-by-hop lexical walk
(os.readlink + os.path.normpath, tracked via a visited set), matching the
existing design in the parallel PR #1280's sandboxed_verify.py. This never
requires a target to exist, so a dangling-but-contained symlink is now
accepted while an actual escape (absolute target, or a normalized target
outside the sandbox root) or an unresolvable cycle (revisiting an
already-followed path) still raises the same ValueError as before. The hop
count is bounded so a chain that never repeats due to purely lexical
normalization still fails closed instead of walking forever.

Added regression tests for: a dangling target with no cycle, a target
excluded from the copy by DEFAULT_IGNORE, and a chain exceeding the hop
limit without ever cycling or escaping.

* fix(sandboxed-verify): fix off-by-one in symlink hop-limit walk

Devin flagged that a valid chain of exactly MAXIMUM_SYMLINK_HOPS (40) real,
OS-resolvable symlinks was incorrectly rejected: each loop iteration checks
one position and, if it is a symlink, advances to the next -- so resolving
N real hops needs N+1 checks (N to walk them, one more to confirm the final
landing position is not itself a further symlink). range(MAXIMUM_SYMLINK_HOPS)
only provided N checks, so the walk always fell through to "could not be
resolved" one check short of reaching a real target on an exactly-N-hop
chain. Reproduced directly: a 40-real-symlink chain terminating in a real
file raised before the fix and copies cleanly after.

Fix: range(MAXIMUM_SYMLINK_HOPS + 1). Verified this doesn't change any
existing test's outcome, including the past-the-limit and cycle-detection
tests (a cycle is still caught well within the budget regardless of the +1;
a chain intentionally longer than the limit still exceeds the new budget
too). Added a boundary regression test for exactly-N-hops success to lock
this in.

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

* fix(sandboxed-verify): resolve symlink targets component-by-component

Devin flagged "nested directory links escape sandbox" on the parallel PR
#1280, which shares this file's hop-walk design: a symlink target that
itself contains an intermediate component which is a symlink (e.g.
"some-alias/../secret") defeats a check that collapses the whole target
string in one os.path.normpath call, because normpath cancels "some-alias"
against the following ".." purely textually without ever re-examining
whether some-alias is itself a symlink needing its own resolution first.

Reproduced directly against this file (not just #1280's): a self-alias
symlink pointing at "." (its own parent, the repo root -- entirely
legitimate and safe standing on its own) combined with a second symlink
whose target is "self-alias/../outside-secret.txt" was NOT caught -- the
whole string collapsed lexically to "outside-secret.txt" (looking safe),
while resolving it for real, one component at a time, correctly shows that
following self-alias lands at the repo root itself (zero depth), so the
very next ".." immediately exits it.

Fix: replaced the whole-target os.path.normpath collapse with a
component-by-component walk that re-checks is_symlink() after every single
path segment, substituting a symlink's own target components back onto the
work queue instead of treating the whole original target string as one
atomic lexical unit. This also fixes the hop-limit off-by-one from
fe237c4 as a natural consequence: 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 special-casing.

Verified against all existing symlink tests unchanged (escape, absolute,
internal, dangling, excluded-by-DEFAULT_IGNORE, cycle, hop-limit boundary)
plus two new ones: the nested-alias escape (must now raise) and a
legitimate cross-directory ".." traversal that stays in-bounds (must still
be accepted, for branch coverage on the successful ".." path).

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

* fix(sandboxed-ci): repo launchers, env templates, shell shadowing, blank 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

* fix(sandboxed-verify): track cycles with a recursive active-set, not 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.

* fix(sandboxed): report a clean coded failure when copy_workspace rejects

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.

* fix(sandboxed-ci): honor explicit --ignore over env-template allowlist; 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

* fix(sandboxed-web-e2e): close proxy bypass, port-occupancy, malformed-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

---------

Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude Code <seongho.iopsy@gmail.com>

Copy link
Copy Markdown
Contributor Author

Attempted merge, aborted — genuine architectural divergence in scripts/ci/sandboxed_web_e2e.py, same class as #1279

Branch is dirty against current main (base recorded here is 1d8e872, from 2026-08-23). Note this PR's own description ("intentionally does not change sandboxed_verify, sandboxed_web_e2e") is stale relative to its actual current head — the branch has since grown to 366+/79- and 445+/135- in exactly those two files respectively (14 commits, last one titled "reap a service leader that exits in the poll/killpg race").

scripts/ci/sandboxed_verify.py (8 conflict blocks) was fully tractable and I resolved it — every conflict there was either a purely-additive independent insertion (e.g. this PR's MAXIMUM_SYMLINK_HOPS constant vs. main's new DEFAULT_ENV_TEMPLATE_ALLOWLIST dotenv-template exception, both kept) or two algorithmically-identical implementations of the same symlink-escape check that converged independently (near word-for-word matching docstrings) with only the exception type differing — standardized on this PR's more specific RepositoryPathBoundaryError/RepositoryRootError since a caller (except RepositoryPathBoundaryError:) already depends on that specific subclass rather than main's plain ValueError, and both a dedicated PATH_BOUNDARY_EXIT_CODE = 122 exit code and a path_boundary_rejected result field already exist to consume it. Not pushed on its own since the PR as a whole doesn't build without the other file.

scripts/ci/sandboxed_web_e2e.py (6 conflict blocks) is not a tractable mechanical merge. The largest single block (177 lines on this PR's side vs. 594 on main's) shows main has independently grown an entire OS-level command-isolation backend this PR's branch has no knowledge of at all: isolation_backend, isolated_command, _probe_isolation_capability, _sandbox_environment, _translate_sandbox_path_entry, _resolve_isolated_executable, _bind_roots — none of these exist on this PR's side. start_service's own signature differs between the two branches (main added a logs_dir parameter this PR's start_service doesn't have). And run_shell still uses plain subprocess.run on main, not yet adopting this PR's own core deliverable (bounded_subprocess.run_bounded_command) at all — so even the PR's central purpose isn't yet reflected in main's current shape of this function. Two independently-evolved, deeply intertwined command-execution/isolation architectures in one security-critical file; picking either side outright would silently drop the other's real behavior, and synthesizing them correctly would mean re-applying the bounded-subprocess integration throughout an isolation subsystem I'd need to build a much deeper understanding of first — the same class of risk #1279 was, not a conflict I should guess my way through.

Merge aborted cleanly, nothing pushed. Given the size of main's independent isolation-backend work, this likely needs the same treatment I suggested for #1279: re-apply this PR's bounded-subprocess integration as a fresh diff against sandboxed_web_e2e.py's current shape, by someone with context on both the isolation-backend feature and the original bounded-output design.


Generated by Claude Code

@seonghobae

Copy link
Copy Markdown
Contributor Author

충돌 해결 시도 결과 — 14블록은 판정 완료, main() 재설계 1블록은 작성자 판단이 필요합니다

브랜치는 2dccde4f 그대로이며 아무것도 푸시하지 않았습니다.

origin/main@f2f91b80 병합 시 충돌은 5개 파일 22블록입니다(merge-base 1d8e8724, 2026-08-30).

좋은 소식: 이 PR의 프리미티브는 무사합니다

scripts/ci/bounded_subprocess.py(468줄)는 main에 대응물이 없는 순수 신규 모듈이고 충돌 없이 병합됩니다. sandboxed_web_e2e.py 안의 bounded_subprocess 참조 31곳 중 16곳은 충돌 에서 깨끗이 병합됩니다.

scripts/ci/sandboxed_verify.py — 8블록 전부 해결했습니다 (합집합)

양쪽 작업이 직교합니다. 브랜치는 심링크 검증을 개명·타입화(validate_repository_symlinks, RepositoryPathBoundaryError/RepositoryRootError, PATH_BOUNDARY_EXIT_CODE), main은 커밋된 dotenv 템플릿 허용목록(DEFAULT_ENV_TEMPLATE_ALLOWLIST + _ignore_with_env_template_allowlist)을 추가했습니다.

한쪽만 취하면 안 됩니다 — 양쪽 심볼 모두 충돌 밖에 참조가 있어 어느 쪽을 버려도 F821이 납니다(_ignore_with_env_template_allowlist 1, DEFAULT_ENV_TEMPLATE_ALLOWLIST 1 / RepositoryPathBoundaryError 1, RepositoryRootError 2, PATH_BOUNDARY_EXIT_CODE 1).

블록 판정
1 theirs만 — ours의 MAXIMUM_SYMLINK_HOPS = 40은 이미 충돌 밖 102행에 정의돼 있어 중복이 됩니다
2·3·4·5·7 ours (브랜치의 개명·타입 예외 체계)
6 합집합 — main의 _ignore_with_env_template_allowlist + 브랜치의 validate_repository_symlinks
8 ours 두 핸들러 + theirs의 except ValueError 를 뒤에 (두 예외가 ValueError 하위 클래스라 순서가 중요하고, RepositoryRootError 핸들러에는 자체 return exit_code가 필요합니다 — 공유되던 return이 theirs 쪽으로 붙기 때문)

적용 후 중복 정의 0, _reject_escaping_symlinks 잔존 0, 구문 OK를 확인했습니다.

scripts/ci/sandboxed_web_e2e.py — 5블록은 판정, 1블록은 막힙니다

블록 1·3·5는 합집합, 블록 4(run_shell)는 ours입니다.

블록 2도 풀립니다. 겉보기엔 최악(ours 74줄 3함수 vs theirs 341줄 10함수, 양쪽이 start_service 정의)이지만, main의 start_service는 merge-base 대비 shell=False 한 줄만 다르고 브랜치 판본에는 이미 그 줄이 있습니다. 그래서 theirs의 신규 10함수 + ours의 3함수, main의 start_service는 버리면 무손실입니다.

막히는 지점 — 블록 6 (main() 본체, ours 115줄 / theirs 101줄)

main이 이 브랜치가 갈라진 뒤 E2E 러너에 OS 수준 격리 백엔드 전체를 새로 지었습니다. merge-base에는 하나도 없던 것들입니다:

심볼 merge-base 브랜치 판본 main 판본
isolation_backend 0 0 5
isolated_command 0 0 11
_sandbox_environment 0 0 2
require_unoccupied_readiness_port 0 0 3
require_loopback_readiness_url 2 7
bounded_subprocess 0 31 0

브랜치의 main()isolation_backend(args.isolation) 실패 시 exit 126, _sandbox_environment(env, sandbox), 백엔드/프론트엔드/E2E 명령을 각각 isolated_command(...)로 감싸기, readiness URL의 SSRF 검사 — 이 전부를 모릅니다. 브랜치 쪽을 그대로 취하면 이것들이 사라집니다.

반대로 main 쪽을 취하면 이 PR의 바운디드 출력 기능(_services_output_limited, OUTPUT_LIMIT_EXIT_CODE, 타입 예외 핸들러 6개)이 사라집니다.

합집합이 기계적으로 나오지 않는 이유: 두 제어 흐름이 같은 함수 안에서 얽혀 있습니다. 격리 백엔드는 명령 문자열을 isolated_command로 감싼 뒤 start_service/run_shell에 넘기는데, 이 PR은 그 두 함수의 시그니처와 반환 타입을 바꿉니다. 올바른 결과는 "바운디드 프리미티브를 격리 백엔드 를 통과해 실행"이고, 그건 병합이 아니라 설계입니다. 게다가 브랜치 쪽에는 main의 격리 기능을 덮는 테스트가 없어서, 제가 만든 합성본이 격리를 조용히 약화시켜도 초록으로 보일 수 있습니다. 샌드박스 격리는 그렇게 다룰 경계가 아닙니다.

제안

main()을 이 두 축으로 재작성하는 것이 이 PR의 남은 작업입니다:

  1. isolation_backend / _sandbox_environment / isolated_command / readiness SSRF 검사(main 흐름)를 먼저 세우고
  2. 그 결과 명령을 이 PR의 start_service(..., service_log_limit_bytes)run_shell(..., output_limit_bytes) 로 실행한 뒤
  3. _services_output_limitedOutputLimitUnsupportedError / RepositoryPathBoundaryError 등 타입 예외 핸들러를 붙이는

순서입니다. 위 14블록 판정은 그대로 쓰시면 되고, 재작업은 블록 6 하나입니다.

수용 오라클

python3 -m pytest -q tests/test_bounded_subprocess.py tests/test_sandboxed_verify.py tests/test_sandboxed_verify_output_limits.py tests/test_sandboxed_verify_symlink_boundary.py tests/test_sandboxed_web_e2e.py tests/test_sandboxed_web_e2e_output_limits.py tests/test_sandboxed_web_e2e_branch_contract.py

여기에 main의 격리 계약(isolation_backend / isolated_command를 덮는 tests/test_sandboxed_web_e2e.py의 기존 테스트)이 함께 초록이어야 합니다.

🤖 Generated with Claude Code

Resolves the 5-file/22-block conflict between this PR's bounded
subprocess/output-limit hardening and origin/main's independently
developed dotenv-template allowlist (sandboxed_verify.py) and OS-level
bubblewrap isolation backend for the web E2E runner
(sandboxed_web_e2e.py).

scripts/ci/sandboxed_verify.py (8 blocks): union of both sides'
orthogonal hardening. Kept this PR's renamed/typed symlink validation
(validate_repository_symlinks, RepositoryPathBoundaryError,
RepositoryRootError, PATH_BOUNDARY_EXIT_CODE) throughout, added main's
committed dotenv-template allowlist (DEFAULT_ENV_TEMPLATE_ALLOWLIST,
_ignore_with_env_template_allowlist) as an independent function, and
kept both typed exception handlers in main() with main's generic
`except ValueError` appended after them -- then removed that generic
handler once coverage proved it fully unreachable (copy_workspace can
now only ever raise the two typed subclasses).

scripts/ci/sandboxed_web_e2e.py (6 blocks): 3 straightforward unions
(module constants, wait_for_url docstring, emit_result payload
fields), run_shell resolved to this PR's bounded version, start_service
resolved to this PR's bounded version plus main's 10 new isolation
helper functions (main's own start_service differed from merge-base by
exactly `shell=False`, already present on this PR's side, so it was
dropped as a pure duplicate). main() was redesigned, not spliced:
isolation_backend/_sandbox_environment/isolated_command/the readiness
SSRF checks now run first exactly as main's flow did, the resulting
(possibly isolation-wrapped) commands are executed through this PR's
bounded start_service(..., service_log_limit_bytes=...) and
run_shell(..., output_limit_bytes=...), and this PR's typed exception
handling (CommandExecutableNotFoundError, CommandNotExecutableError,
OutputLimitUnsupportedError, RepositoryPathBoundaryError,
RepositoryRootError) still wraps the whole flow, with isolation's own
RuntimeError/ValueError rejections caught locally first so they keep
main's exact 126/125 exit codes and messages instead of being
swallowed by this PR's generic (OSError, RuntimeError) catch-all.

Also removed sandboxed_web_e2e.py's now fully-redundant
_require_parseable_command (main's blank/malformed-command validation):
this PR's own earlier parse_args loop already rejects the same inputs
first, so main's check was dead code once merged -- confirmed via
coverage before removing it, and the 6 tests that asserted its
messages were updated to the (already-covered, equally strict) message
text that actually fires.

Test files (test_sandboxed_verify.py, test_sandboxed_web_e2e.py): message
strings updated to match the merged typed-exception contract; two
message-duplicated main tests dropped in favor of this PR's existing
parametrized equivalents; mock signatures in test_sandboxed_web_e2e.py,
test_sandboxed_web_e2e_output_limits.py, test_sandboxed_web_e2e_branch_contract.py,
and test_sandboxed_entrypoint_and_cleanup_coverage.py updated for the
merged start_service/run_shell signatures, and `--isolation disabled`
added to tests that exercise this PR's bounded-output/typed-exception
behavior via fakes or real subprocesses unrelated to main's isolation
feature (which is separately covered by its own now-mocked tests).

Verification: full tests/ suite (3003 passed, 1 skipped), 100% branch
coverage on scripts/ci/, 100% docstring coverage (interrogate), ruff
--select F821 clean, py_compile clean, no duplicate definitions or
leftover references to renamed symbols. Isolation invariant verified
by deliberately reverting the isolation wiring twice (backend/frontend
start_service commands, then separately the e2e run_shell command) and
confirming main()'s own isolation test fails both times -- the second
attempt exposed a real gap (the existing test only proved
isolated_command was *called* for the E2E command, not that its output
reached run_shell), so test_main_runs_required_isolation_with_mapped_environment
was strengthened to assert on run_shell's actual received command and
env before restoring the correct implementation.

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

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Copy link
Copy Markdown
Contributor Author

병합 완료 — main() 재설계 포함 22블록 전부 해결, 초록 확인 후 푸시했습니다

codex/pr931-bounded-subprocess-core-202608242dccde4f5fed0e1c로 갱신됐습니다(머지 커밋, 리베이스 아님, force 없음). main은 그사이 f2f91b80fe827e13로 더 이동했지만, 다시 뜬 충돌은 이전 분석과 동일하게 5개 파일·22블록(merge-base 1d8e8724)이었습니다 — CHANGELOG.md 1, sandboxed_verify.py 8, sandboxed_web_e2e.py 6, test_sandboxed_verify.py 1, test_sandboxed_web_e2e.py 6. 이전 분석의 판정을 실제 현재 diff에 대조 검증하며 그대로 적용했고, 막혔던 main() 블록만 재설계했습니다.

sandboxed_verify.py 8블록 — 이전 판정 그대로 적용, 검증 통과

블록별 판정(theirs만/ours/합집합/양쪽 핸들러+순서)이 현재 diff에도 그대로 들어맞았습니다. 적용 후 MAXIMUM_SYMLINK_HOPS 중복 정의 0, _reject_escaping_symlinks/_resolve_symlink_components 잔존 참조 0, py_compile 통과를 확인했습니다.

sandboxed_web_e2e.py 5블록(main() 제외) — 이전 판정 그대로, 라인 수만 재확인

start_service 블록도 이전 분석대로였습니다: main의 start_service는 merge-base 대비 shell=False 한 줄만 다르고 브랜치 쪽에 이미 있어, main의 10개 신규 함수 전체 + 브랜치의 3개 함수를 취하고 main의 start_service만 버렸습니다.

main() 재설계 — 3단계로 재작성

  1. isolation_backend(args.isolation) → 실패 시 그 자리에서 exit 126 (main의 정확한 메시지/코드 그대로)
  2. _sandbox_environment(env, sandbox) + 세 명령 각각 isolated_command(...)로 래핑(실패 시 exit 126) → readiness URL의 require_loopback_readiness_url/require_unoccupied_readiness_port SSRF 검사(실패 시 exit 125) — 여기까지는 main의 현재 흐름을 그대로 재현
  3. 그렇게 얻은 backend_cmd/frontend_cmd/e2e_cmdcommand_env를 이 PR의 start_service(..., service_log_limit_bytes=...) / run_shell(..., output_limit_bytes=...)에 넘기고, 이 PR의 _services_output_limited/OutputLimitUnsupportedError/RepositoryPathBoundaryError/RepositoryRootError 타입 예외 핸들러가 바깥에서 감쌉니다

격리 쪽 RuntimeError/ValueError는 각자 자리에서 지역적으로 잡아 main의 126/125 계약을 지키게 했습니다 — 그러지 않으면 브랜치의 포괄 except (OSError, RuntimeError) 핸들러("bounded output capture failed"/OUTPUT_LIMIT_EXIT_CODE)가 가로채 main의 계약을 깨뜨립니다.

격리 불변식 검증 — 실제로 회귀를 만들어 테스트가 잡는지 확인했고, 한 번은 진짜 구멍을 발견해서 메꿨습니다

1차: start_service 호출의 backend_cmd/command_envargs.backend_cmd/env(래핑 전 원본)로 되돌려 고의로 격리를 우회시키자 test_main_runs_required_isolation_with_mapped_environment가 즉시 실패했습니다(assert all(item[1].startswith("wrapped ")) 위반).

2차: 같은 방식으로 run_shelle2e_cmd/command_envargs.e2e_cmd/env로 되돌렸더니 — 기존 테스트가 전혀 잡지 못했습니다. wrapped 리스트는 isolated_command가 세 명령 모두에 대해 호출되었다는 것만 기록할 뿐, 그 반환값이 실제로 run_shell에 전달됐는지는 검증하지 않았기 때문입니다. isolated_command("e2e", ...)는 계산되고 버려져도 테스트는 초록이었습니다 — 전체 스위트(3003개) 기준으로도 마찬가지였습니다.

이건 정확히 사전에 우려했던 "조용한 우회"라서, run_shell을 모킹해 실제로 받은 명령/env를 기록하도록 test_main_runs_required_isolation_with_mapped_environment를 보강했습니다(ran[0][0] == "wrapped e2e", ran[0][2]["HOME"]/workspace/로 매핑됐는지). 보강한 테스트로 같은 회귀를 다시 주입해 실패를 재확인한 뒤, 두 회귀 모두 원복하고 최종 구현이 다시 초록임을 확인했습니다.

병합 과정에서 발견한 부수 이슈 — 죽은 코드 2건 제거

  • sandboxed_web_e2e.py_require_parseable_command(main): 브랜치가 parse_args에 이미 넣어둔 앞쪽 루프(shlex.split 검증 + "must not be empty"/"is invalid")가 완전히 같은 입력을 먼저 잡아서, main의 검사(뒤쪽, "must not be blank"/"is not a valid shell command")는 병합 후 도달 불가능해졌습니다(순서상 parser.error가 먼저 종료). 커버리지로 확인 후 제거했고, 그 메시지를 기대하던 6개 테스트를 실제로 발동하는 메시지로 갱신했습니다.
  • sandboxed_verify.pymain() 안 일반 except ValueError(main, 이전 분석이 지시한 대로 브랜치의 두 타입 핸들러 뒤에 붙여뒀던 것): copy_workspace가 던질 수 있는 ValueError는 이제 RepositoryPathBoundaryError/RepositoryRootError 둘뿐이라 이 핸들러도 도달 불가능이었습니다. 100% 커버리지 게이트가 미달 라인으로 정확히 짚어줘서 제거했습니다.

두 경우 모두 해당 동작을 검증하던 테스트는 삭제가 아니라 실제로 실행되는 코드 경로의 메시지에 맞춰 갱신했습니다(동작 자체는 그대로 유지·중복 제거).

검증 수치

  • python3 -m pytest -q tests/test_bounded_subprocess.py tests/test_sandboxed_verify.py tests/test_sandboxed_verify_output_limits.py tests/test_sandboxed_verify_symlink_boundary.py tests/test_sandboxed_web_e2e.py tests/test_sandboxed_web_e2e_output_limits.py tests/test_sandboxed_web_e2e_branch_contract.py180 passed
  • PYTHONPATH=. python3 -m pytest tests -q3003 passed, 1 skipped, 21 subtests passed
  • coverage run -m pytest tests -q && coverage reportscripts/ci 100% (13534/13534 stmts, 5406/5406 branches)
  • interrogate100% (RESULT: PASSED (minimum: 100.0%, actual: 100.0%))
  • ruff check --select F821 . → 클린, py_compile 전체 통과, 중복 정의/잔존 구식 심볼 참조 0

부수적으로 test_sandboxed_web_e2e_output_limits.py의 real-subprocess 테스트 4개와 test_sandboxed_entrypoint_and_cleanup_coverage.py의 1개가 coverage run(하지만 일반 pytest는 아님) 하에서만 실패하는 걸 발견했습니다 — coveragesys.executable/etc/alternatives 경유 심볼릭 링크(/usr/bin/python3)로 바꿔놓는데, 그 중간 심볼릭 링크 타겟이 bwrap의 고정 bind-root 허용목록(/etc/alternatives 미포함)에 없어 샌드박스 안에서 execve가 깨지는, 이 컨테이너 고유의 환경 문제였습니다. main 쪽 실제 서브프로세스 테스트들은 원래부터 이런 경우 --isolation disabled를 명시해 격리 검증과 분리해온 걸 확인하고 동일하게 맞췄습니다(격리 자체는 별도 모킹 테스트로 이미 충분히 덮여 있습니다).

Refs #931. docs/doctoring/sandboxed-output-resource-bounds.md / docs/doctoring/sandboxed-verification-symlink-boundary.md는 변경하지 않았습니다(범위 밖).

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


Generated by Claude Code

…ndary_fails_closed

Every other test in this file that needs to reach start_service directly
passes --isolation disabled. This one didn't, so on a runner without
bubblewrap installed (the actual GitHub-hosted ubuntu-latest runner for
this workflow), main()'s isolation_backend("required") call raises
RuntimeError and returns exit code 126 before the mocked start_service
(and its OutputLimitUnsupportedError) is ever reached, failing the
assertion against OUTPUT_LIMIT_EXIT_CODE (123).

Reproduced locally by mocking shutil.which("bwrap") -> None to simulate
the CI runner: confirmed 126 == 123 failure pre-fix, confirmed pass
post-fix, and confirmed the full suite (3003 passed, 1 skipped) is clean
with the fix applied.

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

Copy link
Copy Markdown
Contributor Author

Fixed Python 3.14 full quality gate failure — real bug, root-caused and fixed (c0dd6da3)

test_unsupported_resource_boundary_fails_closed failed on head 5fed0e1c with assert exit_code == bounded.OUTPUT_LIMIT_EXIT_CODE126 == 123.

Root cause: this is the one test in tests/test_sandboxed_web_e2e_output_limits.py that omitted --isolation disabled — every sibling test that needs to reach start_service directly passes it. With the default --isolation required, main() calls isolation_backend() first; on the actual GitHub-hosted ubuntu-latest runner (no bwrap installed, and trusted-uv-materializer-quality-ci.yml never installs it), that raises RuntimeError and returns exit code 126 before the monkeypatched start_service (which raises bounded.OutputLimitUnsupportedError) is ever called. The test was never exercising the boundary it claims to test on a bwrap-less runner — an environment-dependent gap in the test itself, not a defect in main()'s exception-handler ordering from the earlier merge.

Verification (cloned this exact branch/head locally):

  • Reproduced the CI failure exactly by mocking shutil.which("bwrap") -> None to simulate the runner: 126 == 123, same assertion.
  • Applied the one fix (--isolation, disabled in this test only, matching every sibling test's convention) and reran under the same simulated no-bwrap condition: passes.
  • Full suite under the fix: 3003 passed, 1 skipped, 21 subtests passed (up from 3002 passed, 1 failed).

Only org-wide CI runner-queue congestion (documented elsewhere today) should remain before this is mergeable.

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


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

noema-review and strix failed — same tracked gateway issue, fix in flight

Both failed on this head (c0dd6da3) via the same contextual-orchestrator orchestrator/free route the whole org shares right now. Not this PR's diff (a bounded-subprocess sandboxing change with no LLM-review-path involvement).

Root-cause fix already merged upstream: contextual-orchestrator#1081 (retry-stacking in _invoke's failover loop, commit 414f2297). However it has zero effect yet because the vendoring pin in scripts/ci/contextual_orchestrator_review_sidecar.sh doesn't auto-track main — I have a fix PR open now (fix/bump-contextual-orchestrator-pin-1081) bumping that pin from 2e414d15 (2 days stale) to 414f2297. Once that merges, review sidecars across the org pick up the fix.

Two CodeQL compatibility analysis failures on this same head are the standard self-resolving dispatch-and-poll wait state (already documented elsewhere this session) — no action needed there.

Re-ran both failed jobs once (rerun_failed_jobs on runs 34002713672 and 34002713691); still on the stale pin so I don't expect a different outcome from this specific rerun, but keeping this PR watched until the pin bump lands and a fresh run can actually benefit from it.

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


Generated by Claude Code

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