# task-2783 보고서 — replacement_pr_runner 0바이트 truncate 근본수정 (bounded, ACTIVE=false)

- 작업 ID: task-2783 | 팀: dev3-team (다그다) | 레벨: Lv3 | 상태: 구현 후보(ACTIVE=false)
- 작성: 2026-07-02 KST
- 브랜치: `task/task-2783-dev3` (커밋 62ea27ae) | worktree: `/home/jay/workspace/.worktrees/task-2783-dev3`

## Situation (상황)
QC full-suite 계열 실행 중 canonical 2파일(`utils/replacement_pr_runner.py` 33557B, `tests/regression/test_replacement_pr_runner_2510.py` 24403B)이 동일 mtime으로 **동시 0바이트 truncate**되는 사고가 관측됨(근거: `memory/reports/infra_defect_two_axis_260702.md`).

## Complication (문제)
`utils/replacement_pr_runner.py::transplant_expected_files`의 구조적 결함:
- `:186 cwd = repo_dir or str(WORKSPACE)` — repo_dir 미지정 시 canonical WORKSPACE로 fallback.
- `:195 target.write_text(sr.stdout)` — git show 결과가 empty여도 무검증으로 canonical 파일에 0바이트 O_TRUNC write.
- docstring이 "호출자가 tmp dir을 repo_dir로 줘야 손상 방지"라 경고하나, 코드 자체 방어가 없어 어떤 호출 경로든(자기훼손·finalize 서브프로세스 등) 파괴가 발생 가능.

## Question (핵심 질문)
어떤 호출자가 실수/버그로 empty git-show 또는 잘못된 repo_dir을 넘겨도, canonical 원본이 0바이트로 파괴되지 않게 코드 자체가 fail-closed 방어할 수 있는가?

## Answer (해결) — 2겹 방어 + 회귀
### A. 코드 guard (근본 방어) — `utils/replacement_pr_runner.py::transplant_expected_files`
1. **Guard 1 (repo-root escape 차단)**: `target`이 `repo_root(cwd)` 밖으로 resolve되면 `TARGET_OUTSIDE_REPO_ROOT` RuntimeError. `resolved_target.relative_to(repo_root)` 실패 시 즉시 fail. (path traversal / canonical 밖 write 원천 차단)
2. **Guard 2 (0바이트 덮어쓰기 거부)**: git show 결과가 empty인데 기존 target이 non-empty면 `EMPTY_OVERWRITE_REFUSED` RuntimeError. 0바이트 truncation을 write 직전에 fail-closed로 차단. (신규 파일/의도적 empty 생성은 허용 — target 미존재/이미 empty일 때만 통과)

### B. 테스트 격리 — `tests/regression/test_replacement_pr_runner_2510.py`
- `execute()`를 호출하는 T01/T03에 `repo_dir=str(tmp_path)` 추가 → canonical `/home/jay/workspace` 무접촉. (T04/T05/T07/T13/T14/T15는 기존에 이미 tmp_path 격리됨 → 총 8개 케이스가 tmp_path 사용)

### C. 회귀 테스트 (재발 방지) — 신규 2건
- **T17** `test_t17_empty_git_show_refuses_zero_byte_overwrite`: non-empty target에 empty git-show → `EMPTY_OVERWRITE_REFUSED` 발생 + 원본 크기/내용 보존 검증.
- **T18** `test_t18_target_outside_repo_root_fails`: `../escape_outside.py` → `TARGET_OUTSIDE_REPO_ROOT` 발생 + repo 밖 파일 미생성 검증.

## 수정 파일 목록 (2파일, +본 보고서)
- `utils/replacement_pr_runner.py` (+18/-1, guard 2종)
- `tests/regression/test_replacement_pr_runner_2510.py` (+57/-4, 격리 2 + 회귀 2)
- `memory/reports/task-2783.md` (본 보고서)
- forbidden_paths(finish-task.sh, terminal_state_callback.py, gemini_evidence_verify.py, merge_queue_executor.py, real_merge_hooks.py, dispatch.py, .git/hooks, .github, .claude, .env*, memory/state) **전부 무접촉** — worktree diff `--name-only` = 위 2파일뿐.

## 검증 결과
### pytest (worktree isolated)
- `pytest tests/regression/test_replacement_pr_runner_2510.py` → **21 passed** (기존 19 보존 + 신규 T17/T18). 0.22s.
- 문제 테스트 T01 단독 포함 전부 PASS.

### ★ canonical 2파일 size/sha 불변 증명 (pytest 실행 전/후)
| 파일 | 기대 | BEFORE | AFTER | 판정 |
|---|---|---|---|---|
| utils/replacement_pr_runner.py | 33557B / 95809c89… | 33557B / 95809c89b2f8ba90 | 33557B / 95809c89b2f8ba90 | UNCHANGED |
| tests/…/test_…2510.py | 24403B / 57ebdc51… | 24403B / 57ebdc51bde574d2 | 24403B / 57ebdc51bde574d2 | UNCHANGED |
- **결론: worktree isolated pytest 실행 전/후 canonical 2파일 size+sha 완전 불변 → 0바이트 미발생 증명.**
- 전체 sha256:
  - replacement_pr_runner.py = `95809c89b2f8ba90afb2de98b30d49ceb5d2f011fd6a3345256f907f8556c3b6`
  - test 파일 = `57ebdc51bde574d23322549feb9341c25af1aae550aeac9e5093f5dbf3f3d737`

### L1 스모크테스트 (필수)
- **서버 재시작: 해당없음** (라이브러리/유틸 함수 수정, 서버 무관)
- **API 응답 확인: 해당없음** (HTTP API 아님) — 대신 함수 실동작 검증:
- **함수 실동작(독립 실행)**: 실제 non-empty 파일(4200B)에 empty git-show로 `transplant_expected_files` 호출 → `EMPTY_OVERWRITE_REFUSED` 발생 + 파일 4200B 그대로 보존(0바이트 미발생). repo root 밖 `../../etc_escape.py` → `TARGET_OUTSIDE_REPO_ROOT` 발생 + 파일 미생성. **guard 실동작 확인 완료.**
- **스크린샷: 해당없음** (프론트 작업 아님)
- L1 통과: 함수 실동작 검증 1건 이상 실제 실행 + 통과.

## 종료 검증 함정 준수
- 본 수정본은 **worktree 브랜치에만 존재(canonical 미merge)**. 봇 자신의 finish-task가 canonical(수정 전) 경로로 QC를 돌려 canonical을 truncate하더라도 **정상 예상**이며, 이를 근거로 "canonical 안전해짐"을 주장하지 않음.
- 수정본 효과 증명은 **worktree isolated pytest + 2파일 size/sha 불변**으로만 수행함(위 참조).
- 상태는 **구현 후보(ACTIVE=false)**. canonical 보호 발효는 merge 후(별도 승인).

## ACTIVE=false 불변식 준수
- PR 생성 0 · merge 0 · ci.yml/ruleset 변경 0 · dispatch 추가 0 · real fire 0.
- 산출물 = 수정 2파일 + 로컬 pytest 통과 + worktree 커밋(62ea27ae) + 본 보고서 + ANU 보고.

## 머지 판단
- **머지 필요**: No (ACTIVE=false, merge_policy=none). 아누/회장 별도 승인 후 merge.
- **브랜치**: task/task-2783-dev3 (커밋 62ea27ae)
- **워크트리 경로**: /home/jay/workspace/.worktrees/task-2783-dev3
- **머지 의견**: guard 2종은 순수 방어(fail-closed)로 정상 경로 동작 불변(T04/기존 19건 보존), 회귀 2건 추가. 충돌 위험 낮음. merge 시 canonical 0바이트 파괴 원천 차단 발효.

## 발견 이슈 및 해결
- **이슈**: pre-commit 훅(start_task_guard)이 `.tasks/locks/task-2783.lock` 부재로 커밋 차단.
  - **해결**: 표준 worktree 경로(`.worktrees/task-2783-dev3`)로 재배치 후, clean 상태에서 `start_task_guard.py --task task-2783 --bot dev3` 정식 실행(9개 검증 통과) → lock 생성 → 수정본 재적용 → 커밋 성공.

## 모델 사용 기록
- 루(Lugh, 백엔드): **sonnet** — 코드 guard + 테스트 격리/회귀 파일 편집.
- 다그다(팀장, opus): 설계·worktree 오케스트레이션·size/sha 불변 검증·L1 스모크·보고. (직접 코딩 아님)
- haiku 미사용.

## 세션 통계
- 총 도구 호출: 0회


## 세션 통계
- 총 도구 호출: 0회


## 세션 통계
- 총 도구 호출: 0회

