# task-3072 — 복사 버튼 금소법 우회 + 벤치마킹 맨몸 생성 차단

- 레벨: Lv.2 (라이브 결함) · repo: InsuRo · 담당: dev1
- 브랜치: `task/task-3072` · base: `204d293`
- 지시 전문 sha256(앞16): `a0f0426d8716ba11`
- 상태: **PR 까지 완료 · 머지는 ANU** (지시대로 머지하지 않음)

> ★ 서버 리붓으로 세션이 끊겼다가 재개된 태스크다. 재개 시점에 A·B 구현과
> 테스트 4파일은 이미 있었고 **테스트 1건이 실패 중**이었다. 이번 세션은
> 그 1건 수정 + 검증 전량(회귀·실브라우저·변이) + PR 을 수행했다.

---

## 1. 재개 시점에 남아 있던 실패 1건 — 원인과 수정

`tests/test_task3072_benchmark_required.py::TestBenchmarkStaticPromptAbsence::test_missing_prompt_file_warns_instead_of_silent_empty`

원인은 **문서와 코드의 불일치**였다. `load_skill_prompt` 의 docstring 에는
"의도적 부재가 아닌데 파일이 사라지면 경고 로그를 남긴다" 고 적혀 있었으나
**본문에 그 코드가 없었다** — 파일이 없으면 그냥 `return ""` 했다.
즉 이번 결함의 토양(조용한 빈 프롬프트)이 주석으로만 막혀 있던 상태다.

```python
    if filepath.exists():
        return filepath.read_text(encoding="utf-8")
+   if skill_name not in SKILLS_WITHOUT_STATIC_PROMPT:
+       logger.warning(
+           "skill prompt file missing: %s (%s) — 빈 프롬프트로 생성됩니다",
+           skill_name, filepath,
+       )
    return ""
```

가용성 우선 원칙은 유지했다 — 여전히 빈 문자열을 반환하고 생성을 죽이지 않는다.
`SKILLS_WITHOUT_STATIC_PROMPT`(=`{benchmark}`)는 함수보다 아래에 정의돼 있지만
모듈 전역이라 호출 시점에 해석되므로 문제없다.

결과: **12 passed** (이전 1 failed / 11 passed).

---

## 2. 결함 A — 복사 버튼이 금소법 수정본을 무시했다

### 근본원인
"원본 탭인가 수정본 탭인가" 라는 **같은 판단이 세 곳에 손으로 흩어져** 있었다.
저장(`:465`)과 화면(`:904`)은 맞았고 복사(`:483`)만 그냥 `result` 였다.
한 곳이 뒤처지는 구조라서 고쳐도 재발한다.

### 조치
`src/components/generate/resultSelection.ts` 신설 — 판단을 **한 곳으로** 모았다.
`Generate.tsx` 는 `useMemo` 로 `activeResult` 를 만들고 화면·저장·복사가 **같은 값**을 소비한다.

- `activeResult.body` = 이미지 구획 제거된 본문 → **화면 렌더 · 복사**
- `activeResult.full` = 이미지 구획 포함 전체 → **저장 · 재수정**(왕복 유실 금지, task-3064 의도 유지)

A-2(이미지 프롬프트 혼입)는 `body`/`full` 분리로 함께 해소됐다.
이미지 프롬프트 별도 복사 수단은 `ImagePromptSection` 의 "전체 복사"/개별 복사가
**이미 존재**한다(task-3064). 신설하지 않고 그것을 정식 경로로 확정했다.

---

## 3. 결함 B — 벤치마킹 근거 부재 시 "맨몸 생성"

`req.benchmark` 가 `None` 이면 `benchmark_prompt` 가 조용히 빈 문자열이 됐고,
그 안에 있던 **환각 금지 규칙(`_BENCHMARK_HALLUCINATION_GUARD`)** ·분량·이미지 지시가
통째로 사라진 채 생성됐다. 화면상 스킬은 켜져 있고 에러도 없었다.

### 조치
`BENCHMARK_SKILL_ID in filtered_skills and req.benchmark is None` → **400 거부**.

- 위치가 **큐 제출·토큰 차감 이전**이라 거부해도 사용자 토큰이 소모되지 않는다.
- `filtered_skills` 기준이라 **벤치마킹 권한 없는 플랜은 영향받지 않는다**
  (권한 필터에서 이미 빠지므로 무관한 사용자가 막히지 않는다).
- 사유 상수 `BENCHMARK_GROUNDING_REQUIRED_DETAIL` 은 **할 일까지** 말한다
  ("상위노출 분석을 먼저 실행한 뒤 …"). 사유만 알리면 같은 버튼을 또 누른다.

### 프론트 대응 — 신규 코드 없이 충족(근거)
지시의 "프론트도 대응하라" 는 **기존 코드로 이미 충족**돼 있음을 확인했다.

- `Generate.tsx:331` — task-3064 가드가 `benchmarkLookup?.status !== "ok"` 면
  생성을 진행하지 않고 `"상위노출 분석에서 1위 글의 '벤치마킹 글작성' 버튼으로 다시 들어와주세요."` 를 띄운다.
- `Generate.tsx:402` — 서버 실패 시 `errData.detail` 을 그대로 사용자에게 노출한다.
  → 클라이언트 가드를 우회해 요청이 나가도 서버의 400 사유가 화면에 뜬다.

즉 **클라이언트 가드 + 서버 400 + detail 노출**의 이중 방어다. 중복 UI 를 추가하지 않았다.

### `benchmark.txt` 부재 건 — 판단: **신설하지 않음(의도적 부재로 명문화)**
근거: 이 스킬의 프롬프트는 사용자가 고른 1위 글 근거로 `build_benchmark_prompt()` 가
**매 요청 동적 생성**한다(환각 금지·분량·이미지 규칙이 전부 그 안에 있다).
정적 파일을 두면 동적 블록과 규칙이 **이중화돼 서로 어긋난다** — 더 나쁜 실패다.
스킬 목록에서 제거하는 것도 기능 자체를 죽이므로 불가.
→ `SKILLS_WITHOUT_STATIC_PROMPT` 상수로 **의도를 코드에 명시**하고,
  그 외 스킬의 파일이 사라지면 §1 의 경고가 잡도록 했다.
  `test_every_other_plan_skill_has_a_prompt_file` 이 이 불변식을 봉인한다.

---

## 4. 검증

### 4-1. 회귀 (base 재측정 후 대조 — 승계 아님)

| 스위트 | base `204d293` | head | 차이 |
|---|---|---|---|
| pytest (`server/` 에서 실행) | 3119 passed / 5 skipped / **0 failed** | 3131 / 5 / **0** | **+12** = 신규 테스트 수와 일치 |
| vitest (전체) | 135 files / 1925 passed / **0 failed** | 137 / 1942 / **0** | **+17** = 신규 테스트 수와 일치 |
| `tsc --noEmit` | — | **0 errors** | — |

신규 실패 **0건**. 증가분이 신규 테스트 수와 정확히 일치하므로 기존 테스트 이동/변질 없음.

### 4-2. 실브라우저 검증 (Playwright Chromium · 실제 클립보드 왕복)

jsdom 은 `navigator.clipboard` 가 목이라 "우리 목을 우리가 검사"하는 한계가 있다.
그래서 **실제 `Generate.tsx` 를 그대로 마운트**하는 Vite 하네스를 만들어
(인증·네트워크 의존만 스텁) 실제 Chromium 에서 `clipboard-read/write` 권한을 부여하고
**진짜 클릭 → 진짜 `writeText` → `readText` 로 되읽는** 왕복을 측정했다.

수정본 탭 활성 상태에서 복사한 결과:

```
# 대장암 자가진단 알아볼까요?

## 핵심 정리
이 상품의 수익은 시장 상황에 따라 달라질 수 있으며 원금 손실이 발생할 수 있습니다.
```

- 수정본 포함 ✔ / 원본 금소법 위반 문구 제외 ✔
- `## 이미지 프롬프트` 헤더·항목 3건 전부 제외 ✔ / 본문 온전 ✔
- 클립보드 문자열이 **수정본 본문과 정확히 일치**(`===`) ✔
- 이미지 프롬프트 "전체 복사" → 프롬프트 3건만, **본문 미혼입** ✔

**★ 반대편 대조(counterfactual)**: 같은 하네스를 **base 워크트리**에 붙여 동일 조작을 하니
화면은 수정본인데 클립보드에는 **금소법 위반 원본 + 이미지 프롬프트 전체**가 들어왔다.
사고를 라이브로 재현하고, 수정본에서 사라짐을 같은 방법으로 확인한 것이다.
(하네스는 검증 후 양쪽 워크트리에서 **삭제**했다 — 커밋 대상 아님.)

### 4-3. 변이 테스트 — 11종 전건 KILLED

주입 전 `원문이 정확히 1회 존재`함을 검사하고, 주입 후 **파일 sha 변화**를 확인해
no-op(거짓 SURVIVED)이 아님을 입증했다. 매 회차 원본 복원 후 sha 재대조.

**프론트 5종**

| | 변이 | 판정 |
|---|---|---|
| M1 | 복사 배선을 사고 당시로 복귀 (`activeResult.body` → `result`) | **KILLED** (4 failed) |
| M2 | 이미지 구획 분리 제거 (`body` 를 `full` 로) | **KILLED** (4 failed) |
| M3 | 활성 탭 반영 제거 (항상 원본) | **KILLED** (6 failed) |
| M4 | 저장 경로를 `full` → `body` (재수정 왕복 유실) | **KILLED** (1 failed) |
| M5 | 벤치마킹 게이트 제거 (항상 분리 시도) | **KILLED** (1 failed) |

**서버 6종**

| | 변이 | 판정 |
|---|---|---|
| S1 | 400 가드 통째로 제거 (사고 당시 코드로 복귀) | **KILLED** (4 failed) |
| S2 | 가드에서 스킬 검사 제거 (가용성 파손) | **KILLED** (2 failed) |
| S3 | `filtered_skills` → `req.skills` (플랜 필터링 무시) | **KILLED** (1 failed) |
| S4 | 파일 부재 경고 제거 (§1 회귀) | **KILLED** (1 failed) |
| S5 | `SKILLS_WITHOUT_STATIC_PROMPT` 를 빈 집합으로 | **KILLED** (3 failed) |
| S6 | 거부 사유에서 조치 안내 제거 | **KILLED** (1 failed) |

---

## 5. 남은 것 · 정직한 한계

1. **미머지·미배포.** 지시대로 PR 까지만 했다. 라이브 사용자는 **아직 결함 상태**다.
   §4-2 의 base 재현이 현재 프로덕션에서 일어나는 일 그대로다.
2. **B 의 "실호출" 범위.** 400 거부는 FastAPI `TestClient` 로 앱을 실제 통과시켜
   확인했다(라우팅·검증·가드 전부 실행). 다만 **배포된 `:8001` 라이브 서버에
   HTTP 를 쏜 것은 아니다** — 미배포 상태라 라이브에는 이 가드가 없기 때문이다.
   배포 후 라이브 실호출 재확인이 필요하다.
3. **변이 미커버 1건.** "가드가 토큰 차감보다 앞에 있는가" 는 위치 이동 변이를
   텍스트 치환으로 안전하게 만들지 못해 직접 변이하지 못했다.
   대신 `test_rejection_does_not_queue_a_job`(job_id 부재)로 간접 봉인돼 있다.
4. 지시의 "하지 말 것"(분량/글자수·모델 제한·`generateOptions.ts` 채널 프롬프트)은
   **전부 손대지 않았다** — diff 로 확인 가능.

---

## 6. ANU 전수검사 (2026-08-31 · 봇 자기보고 승계 없이 전량 재측정)

### 6-1. 봇 수치 재현 — 전건 일치

| 항목 | 봇 보고 | ANU 실측 | 판정 |
|---|---|---|---|
| pytest base `204d293` | 3119 / 5 skip / 0 fail | **3119 / 5 / 0** | 일치 |
| pytest head `e2497c2` | 3131 / 5 / 0 | **3131 / 5 / 0** | 일치 |
| vitest base | 135 files / 1925 | **135 / 1925 / 0 fail** | 일치 |
| vitest head | 137 files / 1942 | **137 / 1942 / 0 fail** | 일치 |
| tsc --noEmit | 0 errors | **exit 0 · 출력 0줄** | 일치 |
| npm run build | 성공 | **exit 0** | 일치 |
| 필수 체크 | 8/8 success | **8/8 success** | 일치 |

`ci`·`e2e-test` 상시적색도 독립 확인했다. `ci` 는 base·main 양쪽 failure 이고
실패 스텝은 `Python 의존성 설치`(인프라). `e2e-test` 는 **최근 15연속** 전 브랜치
failure(3064·3066·3067·3068·3069·3070·3071 포함) — 봇이 말한 12연속보다 길다.

### 6-2. 사고 재현 — 커밋된 테스트를 base 에 붙여 직접 확인

봇의 Playwright 하네스는 삭제돼 재실행이 불가능하므로 **다른 방법**으로 같은 결론을 세웠다.
`Generate.copySource.test.tsx` 를 base 워크트리에 그대로 붙여 실행 → **4 failed**.
클립보드에 금소법 위반 원본(`원금이 100% 보장되는 확정 수익`)과 `## 이미지 프롬프트`
구획이 함께 들어오는 것을 재현했다. 새 테스트가 **동어반복이 아님**이 증명된다.
(단 Chromium 실클립보드 왕복 자체는 ANU 가 독립 재현하지 않았다. 봇 주장 중 이 항목만
미검증이며, 커밋된 테스트가 실제 컴포넌트를 렌더해 실제 클릭의 `writeText` 인자를 보므로
결론은 같은 방향으로 독립 확인됐다.)

### 6-3. ANU 자작 변이 7종 — 2종 SURVIVED

봇의 11종과 별개로 ANU 가 설계했다. no-op 방지(주입 전 원문 1회 존재·주입 후 sha 변화·
복원 후 sha 재대조) 적용. ★ 1차 시도에서 테스트 명령에 파이프를 둬 종료코드가 `tail` 의
것이 되는 **거짓 SURVIVED** 함정에 걸렸고, 파이프 제거 후 재판정한 값이 아래다.

| | 변이 | 판정 |
|---|---|---|
| AF1 | `revisedResult` 빈값 폴백 제거 | KILLED (2 failed) |
| AF2 | 반환 객체 `body`/`full` 스왑 | KILLED (3 failed) |
| AF3 | 화면 렌더만 `full` 로 (복사는 유지) | KILLED (1 failed) |
| **AF4** | **`useMemo` 의존성에서 `activeResultTab` 제거** | **SURVIVED** |
| **AS1** | **400 가드를 `create_job` 뒤(큐 제출 직전)로 위치이동** | **SURVIVED** |
| AS2 | `SKILLS_WITHOUT_STATIC_PROMPT` 를 전 스킬로 확장 | KILLED (1 failed) |
| AS3 | 가드 조건 반전 | KILLED (1 failed) |

★ 두 SURVIVED 는 **등가변이가 아니다** — ANU 가 프로브를 따로 짜서 원본 PASS /
변이 FAIL 을 양쪽 다 확인했다. 프로브 보존: `memory/probes/task-3072/`.

**AF4** — 커밋된 테스트는 "수정 반영" 버튼으로 `revisedResult` 와 탭이 **동시에** 바뀌는
경로만 탄다. 사용자가 원본/수정본 **탭 버튼을 직접 누르는** 경로는 어느 테스트도 밟지 않는다.
의존성 배열 하나만 어긋나면 **이번 사고가 그대로 재발**하는데 전량 green 이다.

**AS1** — 보고서 §5-3 은 "위치이동 변이를 못 만들었고 `test_rejection_does_not_queue_a_job`
으로 간접 봉인" 이라 했다. ANU 는 그 변이를 만들었고 **12 passed 로 통과했다**.
해당 테스트는 `"job_id" not in resp.json()` 만 보는데 400 본문은 항상 `{"detail":...}` 이라
**가드 위치와 무관하게 참** — 이 불변식에 대해 공허하다. 간접 봉인은 성립하지 않는다.

두 건 모두 **현재 코드는 옳다**(가드는 `main.py:1902`, `create_job` 은 2027, 토큰 기록은
2083, 큐 제출은 2104 — 줄번호로 순서 확인). 결함이 아니라 **봉인 부재**다.

### 6-4. 봇 판단 검토 결과

- **`benchmark.txt` 미신설 = 타당.** `build_benchmark_prompt()` 는 첫 줄에서 무조건
  `_BENCHMARK_HALLUCINATION_GUARD` 를 넣는다. 퇴화 페이로드(`BenchmarkGrounding()` 전 필드 기본값)
  로 직접 호출해 1,078자 프롬프트에 가드가 포함됨을 확인했다 → `is None` 이 정확한 경계이고
  정적 파일은 실제로 규칙 이중화만 만든다. `skill_prompts/` 실측 7파일 중 `benchmark.txt` 부재,
  `PLAN_SKILL_ACCESS` 6플랜·7스킬로 `test_every_other_plan_skill_has_a_prompt_file` 은 공허하지 않다.
- **프론트 신규 UI 미추가 = 타당.** `Generate.tsx:331` 클라 가드 + `:402` 서버 detail 노출 확인.
- **금지 경로 미변경 = 사실.** 변경 파일은 정확히 6개이며 `generateOptions.ts`·`extension/**`·
  `supabase/migrations/**` 등 forbidden 전건 무변경.

### 6-5. ANU 신규 발견 — `ContentHistoryTab` 복사 (잠복)

`A-2`(이미지 프롬프트 본문 혼입)는 **두 번째 경로에 그대로 남아 있다**.
서버는 `contents.content = result_text` 로 **이미지 구획을 포함한 전체**를 저장하고
(`main.py:2048`, 분리는 클라이언트에서만 한다), `ContentHistoryTab.tsx:61` 의 복사 버튼은
`item.content` 를 그대로 클립보드에 넣는다. 즉 히스토리에서 복사하면 이번에 고친 것과
같은 오염이 발생한다.

★ 다만 **현재 실피해 0**: 프로덕션 `contents` 전수 7행 중 `## 이미지 프롬프트` 포함 행
**0건**(service-role read-only count, `zayhfjuwviporbzokudr`). 라이브 결함이 아니라 잠복이다.
task-3072 범위 밖이므로 머지를 막지 않았다.

### 6-6. 머지 (ANU 실행)

`mergeable_state=behind` 로 1차 머지가 하네스에 차단됨 → `update-branch`(head 핀)
→ 새 head `19824673` → **양방향 델타 감사**(main 포함 ANCESTOR_YES · 원래 head 포함
ANCESTOR_YES · 변경 파일 여전히 정확히 6개 `+840/-10`) → 병합 트리에서 회귀 재측정
(**pytest 3259 / 5 skip / 0 fail**, **vitest 137 files / 1942 / 0 fail**) 으로 task-3071 과의
의미 충돌 없음 확인 → 필수 8/8 재확인 후 squash 머지.

- 머지 커밋 `20eef19a0712d123e52d5b8885b58aed61ef4700` (main)
- squash 이므로 ancestor 검사 대신 **blob 동일성 6/6 MATCH** 로 반영 확인

### 6-7. 머지 후 미해결

1. **배포 미실행** — 라이브는 여전히 결함 상태다. 머지는 배포가 아니다.
   서버 변경(`main.py`)이 포함되므로 `insuro-api` 재기동, 프론트는 Vercel 배포가 필요하다.
2. **봉인 구멍 2건**(§6-3 AF4·AS1) — 코드는 옳으나 테스트가 없다. 프로브는
   `memory/probes/task-3072/` 에 보존했다. 후속 태스크 권장.
3. **`ContentHistoryTab` 잠복 결함**(§6-5) — 현재 해당 행 0건이나 벤치마킹 사용이 늘면 발현한다.
4. **B 의 라이브 HTTP 실호출** — 여전히 미실시(미배포). 배포 후 확인 필요.
