# task-2784 보고서 — P1 WORKSPACE fallback 제거 (safe repo_dir invariant, ACTIVE=false)

- 작업 ID: task-2784
- 팀: dev6-team (팀장 페룬)
- 레벨: Lv3 (코드+테스트+worktree 커밋+ANU 보고까지. **PR 생성 금지** — ANU 별도 승인 후 생성)
- 상태: **구현 후보(ACTIVE=false)** — 미merge. P1 효과는 worktree isolated pytest + canonical size/sha 불변으로만 입증.
- worktree: `/home/jay/.cokacdir/workspace/08468FEE/wt-2784-dev6`
- 브랜치: `task/task-2784-dev6` (base e9e5b34d = main, PR#262 반영본)
- 커밋: `711502ee`

---

## SCQA 요약

**S** (Situation): replacement_pr_runner.py의 5개 write/mutate 경로가 `cwd = repo_dir or str(WORKSPACE)` 구조라, `repo_dir=None`(merge_queue W2 무주입) 또는 `repo_dir=canonical WORKSPACE` 명시 시 canonical `/home/jay/workspace`에 직접 write(0바이트 truncate 포함)가 가능한 구조위험이 상존했다. PR#262(e9e5b34d)는 test 격리+guard만 반영한 partial fix였다.

**C** (Complication): 단순 "repo_dir 필수화"만으로는 `ReplacementPRRunner(repo_dir=str(WORKSPACE))` 우회로 여전히 canonical write가 가능하다. 또한 표준 git worktree는 canonical WORKSPACE **하위**(`.worktrees/...`)에 위치할 수 있어 "경로 parent 차단" 방식은 정상 worktree까지 오차단한다. 동시에 merge_queue W2(dormant real-merge)는 무접촉(옵션 b)이어야 하고, invariant 실패는 반드시 **raise**로 전파돼 W2의 except fallthrough(기존 동작)를 보존해야 한다(회귀 0).

**Q** (Question): canonical WORKSPACE 직접 write만 확실히 차단하면서, 별도/표준 worktree와 정상 tmp sandbox는 허용하고, merge_queue_executor.py는 무접촉으로 두려면 어떻게 판정해야 하는가?

**A** (Answer): **git top-level 기준 판정**을 핵심으로 하는 `_resolve_safe_repo_dir(repo_dir, *, allow_workspace=False)` invariant를 도입했다. `git -C <repo_dir> rev-parse --show-toplevel` 결과가 canonical WORKSPACE와 같으면(=canonical 자체 또는 canonical 하위 일반 디렉터리) 차단하고, 별도 worktree(top-level=worktree root)는 경로가 WORKSPACE 하위여도 허용한다. 5개 fallback 사이트를 모두 이 헬퍼로 교체하고, execute() write 경로 최상단에 raise 전파용 pre-check를 추가하여 W2 except fallthrough를 보존했다. merge_queue_executor.py는 무접촉(옵션 b).

---

## 구현 상세 (utils/replacement_pr_runner.py)

### 1. `_resolve_safe_repo_dir(repo_dir, *, allow_workspace=False) -> str` 헬퍼 (§0.5 신규)
- `repo_dir is None` → `RuntimeError("MISSING_REPO_DIR")` (fail-closed).
- `allow_workspace=True` (CLI 수동 opt-in 전용) → None 체크 후 그대로 통과.
- `Path(repo_dir).resolve() == WORKSPACE.resolve()` → `RuntimeError("WORKSPACE_WRITE_FORBIDDEN")` (명시 우회 차단).
- **git top-level 판정(핵심)**: `git -C <repo_dir> rev-parse --show-toplevel` == canonical WORKSPACE → `WORKSPACE_WRITE_FORBIDDEN`.
  - canonical 하위 일반 디렉터리(top-level=WORKSPACE) → **fail**.
  - 별도 git worktree(top-level=worktree root) → **허용**(경로가 WORKSPACE 하위여도).
- git rev-parse 실패/비-git dir → 보수적 허용(canonical write만 확실히 차단, 정상 tmp sandbox 허용).
- ★ `WORKSPACE in parents` 같은 단순 parent 차단은 **미사용**(표준 worktree 오차단 방지).

### 2. fallback 5곳 helper 통과 (`cwd = repo_dir or str(WORKSPACE)` → `cwd = _resolve_safe_repo_dir(repo_dir, allow_workspace=allow_workspace)`)
`transplant_expected_files` · `commit_local` · `push_branch` · `precheck_local_replacement_diff` · `assert_clean_working_tree` — 각 함수 시그니처에 `allow_workspace: bool = False` 추가.
- `_run`/`_default_runner`(§0)는 범위 제외(호출자가 cwd 명시), `create_clean_replacement_branch`/`open_replacement_pr`도 대상 아님(무접촉).

### 3. execute() write 경로 최상단 pre-check (raise 전파)
`if self.dry_run:` 분기 직후, dirty-tree try 블록 앞에 `_resolve_safe_repo_dir(self.repo_dir, allow_workspace=self.allow_workspace)`를 **try/except 없이** 삽입. → invariant 위반이 execute() 밖으로 raise되어 merge_queue W2의 except가 잡아 기존 fallthrough(DIFF_CONTAMINATION_REPLACEMENT) 보존. `return success=False`로 삼키지 않음(§6 회귀 보존).
- execute() 내 5개 함수 호출에 `allow_workspace=self.allow_workspace` 전달. `ReplacementPRRunner.__init__`에 `allow_workspace: bool = False` + `self.allow_workspace`.

### 4. CLI opt-in
`main()`에 `--allow-workspace` 플래그 추가, `ReplacementPRRunner(dry_run=..., allow_workspace=args.allow_workspace)`. automation/merge_queue/real-merge 경로는 `allow_workspace=False`(기본) 유지.

### 5. 보존 (회귀 0)
- transplant guard 순서 불변: GIT_SHOW_FAILED → TARGET_OUTSIDE_REPO_ROOT → symlink unlink → write.
- rc0+empty stdout = 정상 empty blob 허용 유지(EMPTY_OVERWRITE 로직 재도입 안 함).

---

## 테스트 결과 (worktree isolated)

`pytest tests/regression/test_replacement_pr_runner_2510.py` → **30 passed in 0.20s** (기존 24 + 신규 6).

신규 회귀 6종:
- T21: `repo_dir=None` → `MISSING_REPO_DIR` fail + assert_clean_working_tree 전파.
- T22: `repo_dir=canonical WORKSPACE`(경로 동일) → `WORKSPACE_WRITE_FORBIDDEN` + 무접촉.
- T23: canonical 하위 일반 디렉터리(git top-level=WORKSPACE) → `WORKSPACE_WRITE_FORBIDDEN`.
- T24: 별도 tmp git repo(top-level != WORKSPACE) → 정상 transplant 동작.
- T25: merge_queue W2 회귀 — `ReplacementPRRunner(repo_dir 없음).execute` contaminated 경로 → `MISSING_REPO_DIR` **raise**(W2 except fallthrough 보존 시뮬).
- T26: 정상 empty-file PR(rc0+empty, tmp repo_dir) → 허용 유지.

기존 T17/T17b/T18/T19/T20(symlink/GIT_SHOW_FAILED/TARGET_OUTSIDE) 회귀 0. 기존 T05/T14/T15 monkeypatch는 execute의 allow_workspace 전달에 맞춰 시그니처 확장(동작 불변).

---

## L1 스모크테스트 결과 (실증 — pytest PASS ≠ 실동작)

subprocess/라이브러리 모듈이므로 서버/API 아님. **실제 git 디렉터리에 대해 git top-level 판정을 실동작 검증**:
- 서버 재시작: 해당없음 (라이브러리 모듈).
- API 응답 확인: 해당없음.
- 스크린샷: 해당없음.
- **실동작 검증 (실제 경로, 모듈 WORKSPACE=/home/jay/workspace)**:
  - `_resolve_safe_repo_dir('/home/jay/workspace')` → `WORKSPACE_WRITE_FORBIDDEN` ✅ (canonical 자체)
  - `_resolve_safe_repo_dir('/home/jay/workspace/utils')` → `WORKSPACE_WRITE_FORBIDDEN` ✅ (canonical 하위 일반 디렉터리, git top-level=WORKSPACE)
  - `_resolve_safe_repo_dir('/home/jay/.cokacdir/workspace/08468FEE/wt-2784-dev6')` → 경로 반환(허용) ✅ (별도 worktree, top-level=worktree root)
  - `_resolve_safe_repo_dir('/home/jay/workspace/.worktrees/task-2783-dev3')` → 경로 반환(허용) ✅ (**WORKSPACE 하위**의 표준 worktree, top-level=worktree root → 단순 parent 차단이 아님을 실증)
- `_resolve_safe_repo_dir(None)` / `(None, allow_workspace=True)` → 둘 다 `MISSING_REPO_DIR` ✅ (fail-closed 우선).

L1 통과: 실제 canonical/subdir/worktree 4종 경로에 대한 git top-level 판정 실동작 확인.

---

## 검증 항목 (지시서 §검증)
- pytest 전체 PASS: **30 passed** (개수 명시).
- diff **2파일**뿐: `utils/replacement_pr_runner.py`(+74/-23 순), `tests/regression/test_replacement_pr_runner_2510.py`. merge_queue·forbidden 무접촉 확인(`git diff --name-only | grep -E "merge_queue|real_merge|..."` → NO_FORBIDDEN_TOUCH).
- **canonical 2파일 size/sha 불변**:
  - utils/replacement_pr_runner.py: 34665 / `5c38e271...` (전후 동일)
  - tests/regression/test_replacement_pr_runner_2510.py: 31137 / `d635c025...` (전후 동일)
- merge_queue_executor.py 무접촉(옵션 b): W2 회귀는 replacement 측 "repo_dir 없으면 raise"만 검증(T25).

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| /home/jay/.cokacdir/workspace/08468FEE/wt-2784-dev6/utils/replacement_pr_runner.py | safe repo_dir invariant 헬퍼 + 5 사이트 치환 + execute pre-check + CLI opt-in | grep "_resolve_safe_repo_dir" OK | verified |
| /home/jay/.cokacdir/workspace/08468FEE/wt-2784-dev6/tests/regression/test_replacement_pr_runner_2510.py | 신규 회귀 T21~T26 + 기존 monkeypatch 갱신 | grep "test_t25_w2_missing_repo_dir_execute_raises" OK | verified |

---

## 발견 이슈 및 해결
- **이슈**: execute()가 assert_clean_working_tree의 RuntimeError를 try/except로 삼켜 `success=False`로 반환하면, merge_queue W2가 Critical escalation 분기로 빠져 dormant 동작이 바뀜(설계 §6 금지).
- **해결**: invariant 선검사를 execute() write 경로 최상단(어떤 try/except에도 감싸지 않음)에 배치하여 RuntimeError가 execute() 밖으로 raise 전파되도록 함. T25로 회귀 고정.

## 모델 사용 기록
- 스바로그(백엔드, utils/replacement_pr_runner.py 구현): **sonnet**.
- 벨레스(테스터, 회귀 6종 + monkeypatch 갱신): **sonnet**.
- 팀장(페룬, Opus): 설계/분배/통합/검증/커밋/보고. haiku 미사용(로직 정밀도 요구).

## 머지 판단
- **머지 필요**: No (본 task는 PR 생성 금지 — ANU 독립 검증 후 별도 승인 시에만 생성).
- **브랜치**: `task/task-2784-dev6`
- **워크트리 경로**: `/home/jay/.cokacdir/workspace/08468FEE/wt-2784-dev6`
- **머지 의견**: 2파일 diff, 30 pytest PASS, canonical size/sha 불변, merge_queue 무접촉, git top-level 판정 실동작 확인. 구현 후보(ACTIVE=false)로 ANU 독립 재검증 대상. real-merge 활성화/옵션(a) 확장은 별도 안건.

## 종료 검증 함정 준수
- finish-task는 canonical(PR#262 반영본) 기준 QC를 돌리므로 **finish-task 종단으로 P1 효과를 주장하지 않음**. P1 수정본 효과는 위 worktree isolated pytest(30 PASS) + canonical size/sha 불변으로만 입증. 완료해도 구현 후보(ACTIVE=false).

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

