From 2c607e0df6d44632bb009af1f8a6c40772a16363 Mon Sep 17 00:00:00 2001 From: changukyu Date: Sat, 11 Jul 2026 16:08:04 +0900 Subject: [PATCH] docs: 2026-07-11 multi-agent review report, remediation checklist, offline extras - Add the 2026-07-11 multi-agent project-review report and the remediation checklist (per-item status, with deferred items and their rationale). - offline-install: document Ollama model and Playwright Chromium transfer, a hash-verified lock, --only-binary, and correct the egress note (image comparison fetches arbitrary SSRF-guarded public URLs, not only API hosts). --- docs/operations/offline-install.md | 64 +++++++++- docs/reviews/2026-07-11-project-review.md | 112 ++++++++++++++++++ .../2026-07-11-remediation-checklist.md | 107 +++++++++++++++++ 3 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 docs/reviews/2026-07-11-project-review.md create mode 100644 docs/reviews/2026-07-11-remediation-checklist.md diff --git a/docs/operations/offline-install.md b/docs/operations/offline-install.md index f1ea977..e051315 100644 --- a/docs/operations/offline-install.md +++ b/docs/operations/offline-install.md @@ -13,15 +13,29 @@ carried across the air gap as pre-downloaded wheels. The build/staging machine ## 1. On the build machine (has internet) ```bash -# Runtime only: -pip download -r requirements.txt -d wheelhouse/ +# Runtime only (--only-binary keeps sdists out so nothing has to build on the +# offline target; also carry pip/setuptools/wheel themselves): +pip download --only-binary=:all: -r requirements.txt -d wheelhouse/ +pip download --only-binary=:all: pip setuptools wheel -d wheelhouse/ # Or runtime + test tooling: -pip download -r requirements-dev.txt -d wheelhouse/ +pip download --only-binary=:all: -r requirements-dev.txt -d wheelhouse/ ``` This fills `wheelhouse/` with every wheel (including transitive deps). +### Hash-verified lock (recommended) + +Pin the full transitive tree with hashes so a tampered/substituted wheel is +rejected at install time: + +```bash +# On the build machine (pip-tools): +pip-compile --generate-hashes -o requirements.lock requirements.txt +``` + +Then install on the target with `--require-hashes` (step 3). + ## 2. Transfer Copy both the repository and the `wheelhouse/` directory to the target via the @@ -46,14 +60,56 @@ python -c "import numpy, cv2, PIL; print('deps ok')" python -m pytest -q # if test deps were installed ``` +## Optional: Ollama LLM model (evidence narration) + +The LLM assistant is optional. If you want it on the air-gapped target, the +model weights must be carried across too — they are **not** part of the Python +wheelhouse: + +```bash +# Build machine (has internet): +ollama pull qwen2.5:0.5b-instruct # or your pinned OLLAMA_MODEL +# Copy the model store across the air gap: +# default: ~/.ollama/models (or set OLLAMA_MODELS to a directory you transfer) + +# Target machine (no internet): +export OLLAMA_MODELS=/path/to/transferred/models +ollama serve & +# Smoke test: +ollama run qwen2.5:0.5b-instruct "hello" +``` + +Set `OLLAMA_BASE_URL` / `OLLAMA_MODEL` in `.env`. Leave them unset to run without +the LLM (the `llm` provider then reports disabled). + +## Optional: Playwright Chromium (E2E tests) + +The operator-GUI E2E suite needs a Chromium binary, which Playwright normally +downloads. For the air gap, fetch it on the build machine and carry it: + +```bash +# Build machine: +PLAYWRIGHT_BROWSERS_PATH=./pw-browsers playwright install chromium +# Transfer the pw-browsers/ directory to the target, then: + +# Target machine: +export PLAYWRIGHT_BROWSERS_PATH=/path/to/pw-browsers +python -m pytest tests/operator_gui -q +``` + ## Notes - The operator GUI (`web/operator-gui/`) is fully self-contained: all scripts/styles are local and the Pretendard font is bundled (`assets/fonts/PretendardVariable.woff2`). No CDN fetch at runtime. - External **search** APIs (Google / Naver / Cloud Vision) are an allowed - runtime exception and require outbound access to those providers ONLY. They + runtime exception and require outbound access to those providers. They are optional; missing credentials disable the provider (see the startup provider-readiness log). +- **Broader egress:** beyond the API hosts, the image-comparison feature also + fetches arbitrary public result-page / image / stylesheet URLs returned by + those searches (SSRF-guarded: private/loopback/CGNAT/link-local ranges are + blocked). Restricting egress to the API hosts only disables image comparison + of external results; it does not break core review. - `COPYRIGHTER_AUTH_TOKEN` should be set in `.env` for any multi-operator or networked deployment so the data routes require a shared token. diff --git a/docs/reviews/2026-07-11-project-review.md b/docs/reviews/2026-07-11-project-review.md new file mode 100644 index 0000000..f0c55cd --- /dev/null +++ b/docs/reviews/2026-07-11-project-review.md @@ -0,0 +1,112 @@ +# Copyrighter 프로젝트 코드 리뷰 — 2026-07-11 + +> 다중 에이전트 리뷰(리뷰 유닛 9개 + 백로그 전수 대조 1개, 총 33 에이전트) · 신규 CRITICAL/HIGH 발견은 독립 검증자 2명의 적대적 검증(반박 시도)을 통과한 것만 채택. +> 기준 커밋: `89c1fe4` (Phase 1 HIGH remediation) · 백엔드 테스트: **361 passed** (pytest, 3m56s). +> 지난 리뷰에서 레이트리밋으로 실패했던 arch-concurrency 유닛 재실행 완료 — 이번 신규 HIGH 9건 중 4건이 이 유닛에서 나옴. +> 신규 발견: **HIGH 9 · MEDIUM ~24 · LOW ~12 · CRITICAL 0** (기존 백로그 재발견분 제외). + +## 1. 총평 (Executive Summary) + +**프로젝트 건강도: 주의 (Phase 1 수습은 성공, 그러나 더 깊은 계층에서 새 HIGH 9건 발견)** + +2026-06-22 리뷰(66건: CRITICAL 0 / HIGH 6 / MEDIUM 43 / LOW 17) 이후 commit 89c1fe4로 Phase 1 HIGH 5건이 실제로 수정되었음을 코드 레벨에서 확인했다. 그러나 이번 심층 리뷰(동시성·직렬화·시드 경로 집중)에서 **신규 HIGH 9건(전건 검증 CONFIRMED), MEDIUM 약 24건, LOW 약 12건**이 나왔다. CRITICAL은 이번에도 0건. + +지금 가장 큰 리스크 3가지: + +1. **프로덕션 스코어링이 조용히 무력화되어 있음 (H6, H7)** — `_evidence_payload`/`_domain_evidence_from_ui`(src/rights_filter/server/store_serialization.py:260, 302)가 `promoted`와 `entity` 필드를 직렬화에서 누락한다. 모든 프로덕션 점수는 `_rescore_submission`의 라운드트립을 거치므로, **Naver 프로모션 근거(+40)와 Google 웹 엔티티 근거(+40)가 프로덕션에서 항상 0점**이다. GUI에는 80% 신뢰도로 표시되면서 위험도는 'low'에 머무는, 이 도구의 핵심 기능(권리 리스크 필터링)의 침묵 결함이다. 89c1fe4의 entity-bucket dedup 수정도 프로덕션 경로에서는 사실상 죽은 코드다. +2. **긴급 제공자 차단(emergency disable)이 내구성이 없음 (H2, H3)** — `_sync_provider_payloads`(store_enrichment.py:49)는 reload/수집 때마다 저장된 `enabled` 플래그를 자격증명-연결성 기본값으로 덮어쓰고, `_apply_provider_usage_delta`(store_persistence.py:238)는 검색 시작 시점에 캡처한 stale payload 전체를 되써서 진행 중이던 검색이 끝나면 차단을 되돌린다. 컴플라이언스 목적의 긴급 정지가 감사 기록 없이 무효화된다. +3. **`_write_lock` 규율이 절반만 적용됨 (H1, H4)** — 89c1fe4는 `mark_evidence_status`와 promote 경로에만 락을 넣었고, `record_decision`(sqlite_store.py:569)과 `manual_search`(store_operations.py:321)는 여전히 무락 RMW다. 결과: **운영자의 승인/반려 결정이 동시 rescore/검색에 의해 조용히 'unreviewed'로 되돌아갈 수 있으며**, 감사 로그·워치리스트와 실제 상태가 어긋난다. + +전 회차 대비 평가: 지난 리뷰가 표면(엔드포인트·GUI·개별 함수) 중심이었다면, 이번엔 "전 제출건이 단일 JSON blob + ThreadingHTTPServer" 구조에서 파생되는 **lost-update 계열**과 **직렬화 라운드트립 손실 계열**이라는 두 개의 구조적 결함군이 드러났다. 개별 수정보다 패턴 단위 일괄 적용이 필요하다. + +## 2. Phase-1 수정 검증 판정 + +**판정: 5건 모두 커밋 내용대로 존재하고 회귀 테스트 4건 통과. 단, "좁게 맞은 수정" — 리뷰 항목 기준 4 FIXED / 1 PARTIAL.** + +| # | 항목 | 판정 | 근거 | +|---|---|---|---| +| 1 | `mark_evidence_status` 원자성 | **FIXED** | sqlite_store.py:601-623 — `_write_lock` + 단일 `_transaction`, conn 스레딩, 롤백 테스트 존재 | +| 2 | `promote_collection_candidates` 락+멱등성 | **FIXED** | store_operations.py:699-771 — 락, 충돌-안전 `_stable_id`, 락 하 already-promoted 가드 | +| 3 | `deactivateCorrectionEntry` / provider-retry 정직화 | **FIXED** (단서 有) | app.js:3247-3256 실제 API 호출, :3676-3684 정직한 미구현 안내. 단, 연결된 서버 엔드포인트가 비멱등이라 파생 항목 1건이 이미 비활성이면 버튼이 영구 고장 (신규 M16) | +| 4 | entity-bucket 점수 dedup | **PARTIAL** | risk_scoring.py:102-104 핵심 수정 + 테스트 2건 존재. 번들이던 LOW(카테고리 우선순위 미러링) 미적용. **더 중요하게는 H7로 인해 프로덕션 경로에서는 이 수정 자체가 작동 불능** | +| 5 | `runManualSearch` in-flight 가드 | **FIXED** (단서 有) | app.js:2384/2390/2436/2460, rerun은 :2488 void 처리. 단, 가드 발동 시 무피드백 + 폼 덮어쓰기(신규 M17), 검색 완료 시 입력창 초기화(LOW) | + +핵심 교훈: 락 수정의 주석(sqlite_store.py:598-600 "다른 writer가 끼어들지 않는다")은 **락을 잡는 writer에 대해서만** 참이다. `record_decision`, `manual_search`, `set_provider_enabled`, `emergency_disable`, `collect_keyword_candidates`, `_sync_submission_provider_state`가 락 밖에 남아 수정의 보증을 우회한다. + +## 3. 신규 발견 (유닛 간 dedup 완료, HIGH는 전건 2-표 검증) + +### HIGH — 9건 전건 CONFIRMED + +| # | 위치 | 요약 | 발견 유닛 | +|---|---|---|---| +| H1 | sqlite_store.py:569 | `record_decision` 무락 RMW — `_rescore_submission`(store_search_candidates.py:721-731)·bulk sweep과 경합 시 운영자 결정이 'unreviewed'로 조용히 복원, 감사로그/워치리스트와 상태 불일치 | fix-verify(M), **arch-concurrency(H)**, store-integrity(M) — 3개 유닛 교차 발견 | +| H2 | store_persistence.py:238 | `_apply_provider_usage_delta`가 검색 전 캡처한 provider payload 전체를 되써서 in-flight 검색 종료 시 enable/disable(긴급 차단 포함) 되돌림 | arch-concurrency | +| H3 | store_enrichment.py:49 | `_sync_provider_payloads`가 저장된 `enabled`를 연결성 기본값으로 덮어씀 — 모든 reload/import/upload/수집이 차단을 무감사 해제, collect의 disabled 체크(store_operations.py:575-578)는 항상 무효 | arch-concurrency | +| H4 | store_operations.py:321 | `manual_search`가 수 초~수십 초 외부 검색 전에 읽은 stale payload를 :337-347에서 무락 재기록 — 검색 중 내려진 결정/근거상태 덮어씀 | arch-concurrency | +| H5 | sqlite_store.py:285 | 0바이트/손상 이미지 1장이 `analysis_runs_for_submission(...)[-1]` IndexError로 **모든 import/reload/upload를 지속 마비**(부분 커밋 잔류, 파일 수동 삭제 전까지 반복) | store-integrity | +| H6 | store_serialization.py:260 | 직렬화가 `promoted` 누락 → 프로덕션에서 Naver 검색 근거 상시 0점 (GUI엔 80% 표시, contributed=true 거짓 표기) | analysis-scoring | +| H7 | store_serialization.py:302 | 직렬화가 `entity` 누락 → Google 웹 엔티티 상시 0점, rerun 시 엔티티 기반 쿼리 생성도 불가, 89c1fe4 dedup 수정 프로덕션 무력화 | analysis-scoring | +| H8 | index.html:227-229 | 이미지 패널 버튼(맞춤/1:1/회전) 핸들러 전무 — 죽은 버튼 (합성 단계 재검증: JS 전체에 해당 id 참조 0건) | frontend | +| H9 | app.js:2943-2955 | 후보 '무시'가 로컬 splice뿐 — 서버 엔드포인트 부재(src/ 내 dismiss 0건), 모든 bootstrap 갱신 시 무시한 후보 부활 (합성 단계 재검증 완료) | frontend | + +### 검증 결과 강등 (HIGH → MEDIUM) + +| # | 위치 | 요약 | 판정 | +|---|---|---|---| +| D1 | store_serialization.py:349 | skip-사유 키워드 휴리스틱이 컴플라이언스 skip을 FAILURE(+30)로 오복원 — **단, 현 wiring에서는 트리거 문자열이 도달 불가능**(compliance 플래그가 어댑터 존재와 동치, env_clients.py:99-118). 잠재 지뢰로서 MEDIUM. 수정은 H6/H7과 같은 방법(도메인 source enum 영속화)으로 함께 처리 | DOWNGRADE (1표 REFUTED, 1표 DOWNGRADE) | + +### MEDIUM — 주요 항목 (유닛 판정 그대로) + +**동시성/저장소** +- M1 store_operations.py:584 — `collect_keyword_candidates` 무락 전체 삭제+재적재: promote와 경합 시 404 또는 stale 'promoted' 행 부활, 어댑터 예외 시 이전 후보 전멸 *(fix-verify·arch-concurrency·store-integrity 3중 발견)* +- M2 sqlite_store.py:630/647 — `set_provider_enabled`/`emergency_disable` 무락 RMW(usage 증분 유실, 비원자 sweep) *(arch-concurrency·store-integrity)* +- M3 store_enrichment.py:56-71 — `_sync_submission_provider_state` 전 테이블 무락 sweep이 무관한 제출건의 동시 갱신까지 되돌림 +- M4 sqlite_store.py:233 — seed가 `_write_lock`을 쥔 채 레코드당 외부 HTTP 수행 → 대량 import 동안 락 필요 엔드포인트 전부 무한 대기 +- M5 http_app.py:387-396 — 업로드 경로 exists()/write TOCTOU: 동명 파일 동시 업로드 시 한쪽 바이트 소실 +- M6 store_queue.py:92-119 — 큐 활성 전환/레거시 이관에 감사 이벤트 전무 +- M7 store_schema.py:249 — CHECK 위반 레거시 행 1건이 서버 기동을 영구 차단(수복 경로 없음) +- M8 image_store.py:93 — 동일 stem 파일(a.jpg/a.png)이 한 제출건으로 병합, 한 이미지는 미심사 통과 +- M9 store_queue.py:119 — 레거시 무큐 제출건이 새 큐에 입양된 뒤 prune에서 근거까지 연쇄 삭제 (심사 이력 소실 시나리오) + +**HTTP 보안 (신규만)** +- M10 http_app.py:277 — 음수 Content-Length가 10MB 캡 우회 → `rfile.read(-1)` EOF까지 읽어 메모리 고갈 +- M11 http_app.py:65 — 핸들러 timeout 부재 + 무제한 스레드 → slowloris로 스레드 고갈 + +**스코어링/프로모터** +- M12 search_result_promoter.py:17 — blog/web "no results" 근거가 가드를 빠져나가 쿼리 텍스트 매칭으로 0.8 승격 (0건 검색이 80% 신뢰도로 표시) +- M13 search_result_promoter.py:36 — 마커가 영어 11개뿐: 한국어 Naver 결과 체계적 미탐 + 'iu'/'game'/'cover' 오탐 +- M14 risk_scoring.py:38-42 — promoted Naver 결과 아이템당 +40 무상한 적층 (쿼리 1건 결과 2개면 high 밴드) — H6 수정 시 즉시 프로덕션 노출되므로 동시 수정 필수 +- M15 evidence_enrichment.py:58 — (인메모리 경로 한정) KB 전 항목이 쿼리 예산 잠식, 'rejected:{id}' 항목이 문자 그대로 검색어화 + +**프론트엔드** +- M16 app.js:3249 — deactivate 루프 비멱등·부분 실패 시 refresh 없음 → 이미 비활성 항목 1건이 버튼 영구 고장 *(fix-verify·frontend)* +- M17 app.js:2484 — rerun이 폼을 덮어쓴 뒤 무피드백 no-op +- M18~M23 — addKnowledgeEntry의 수집탭 초기화(:2879), executeSuggestedQueries 미처리 rejection(:1052), runCandidateCollection 선-클리어(:3005), 영구 잔존 apiError 배너(:234), 8개 클릭 mutation의 in-flight 가드 부재(:3185 등), `renderCorrections`의 `derivedEntries` 무가드 참조(:2030, renderAll 전체 중단 가능) + +**LOW (~12건, 대표)**: 검색 완료 시 입력 wipe(app.js:2448), batch re-promote 무음 폐기(store_operations.py:713), keep-alive 커넥션 Set-Cookie 잔류(http_app.py:264), Content-Length 파싱 오류 내부 메시지 노출(:277), FK check 미수행(store_schema.py:218), face-crop 보존기한 mtime 기준(store_enrichment.py:202), 죽은 category 분기(risk_scoring.py:87), exact: 지문 비교 불능(fingerprints.py:28), 프론트 'undefined' 렌더/스테일 패널/CSS 누락 클래스 등. + +*(knownBacklog 재발견분 — ensure_queue UPDATE 키 버그(store_queue.py:114), rerun 경로 무락(backlog #12), CSP/쿠키/rate-limit 등 sec-http 6건, 프론트 5건 — 은 위 표에서 제외, 4절에 반영)* + +## 4. 백로그 현황 (2026-06-22 로드맵 26개 항목) + +**집계: FIXED 4 / PARTIAL 3 / OPEN 20 / REGRESSED 0** (backlog-status 유닛 전수 코드 대조 결과) + +- **Phase 1 (1-5)**: 1,2,3,5 FIXED / 4 PARTIAL (LOW 서브픽스 누락 + H7로 프로덕션 무력) +- **Phase 2 보안 (6-11)**: **전 6건 OPEN** — 앱셸 CSP/XFO 부재(http_app.py:314-345), 원시 토큰 쿠키·Secure 부재(:291), 토큰 엔트로피/rate-limit 부재, `UrllibJsonTransport` 무제한 read(http_json.py:39), Origin CSRF 폴백 부재, Google API 키 URL 노출(cloud_vision_web_detection.py:57) +- **Phase 3 (12-20)**: 16 PARTIAL(LLM 사유만 필터, skip 사유는 여전히 잔류 — risk_scoring.py:57-63) / 나머지 8건 OPEN (12 rerun 무락, 13 ensure_queue, 14 무음 예외, 15 lastFailure 오귀속, 17 GUI 가드, 18 dedup 핫패스 전체 스캔, 19 EXIF 무경고 폴백, 20 reasons 가변 list) +- **Phase 4 (21-24)**: 전건 OPEN — 21은 89c1fe4에서 의도적 유예된 HIGH #6(_auto_* 메가함수, store_enrichment.py:391/:557) +- **Phase 5 (25-26)**: 25 PARTIAL(롤백·dedup 테스트 2건 추가, 미디어 401/untrusted 헤더/safeUrl/SSRF/E2E 등 다수 미작성) / 26 OPEN(offline-install.md에 Ollama·Playwright·lock 해시 부재) +- **Phase 6 LOW 배치**: 전건 OPEN + +## 5. 차기 스프린트 권고 (risk-per-effort 순) + +1. **[최우선] 근거 직렬화 라운드트립 복구** — store_serialization.py `_evidence_payload`/`_domain_evidence_from_ui`에 `promoted`·`promotionReason`·`entity`(·`category`) 필드 추가 + 도메인 `EvidenceSource` enum 직접 영속화(키워드 휴리스틱은 레거시 폴백으로 강등). H6·H7·D1을 한 번에 해소. **store 레벨 회귀 테스트**(auto-search → `_rescore_submission` → riskScore > 0 단언) 필수. 작은 diff, 핵심 기능 복구. +2. **[동시 필수] 프로모터 정합성** — 1번을 배포하면 M12(0건 검색 0.8 승격)·M13(영어 마커)·M14(+40 무상한 적층)가 곧바로 프로덕션 점수에 노출된다. search_result_promoter.py의 no-results 구조적 매칭 + 쿼리 텍스트 스캔 제외 + NAVER_SEARCH 기여 상한/쿼리별 dedup(risk_scoring.py)을 **같은 PR**로. +3. **[한 줄 수정] seed IndexError 가드** — sqlite_store.py:285에서 빈 run 리스트 시 'failed' 밴드 제출건 생성+감사 이벤트 후 continue. 0바이트 파일 1장으로 전 import 마비되는 H5 제거. +4. **provider enabled 내구성** — `_sync_provider_payloads`가 저장된 `enabled` 보존(연결 안 될 때만 강제 off), `_apply_provider_usage_delta`는 최신 행에 델타 병합(stale payload 전체 되쓰기 금지), `_auto_*` 드라이버가 DB 플래그 확인, `set_provider_enabled`/`emergency_disable`에 `_write_lock`+단일 트랜잭션. H2·H3·M2 해소 — 긴급 정지의 신뢰성 회복. +5. **`_write_lock` 일괄 적용** — `record_decision`(H1), `manual_search`(H4, 외부 I/O는 락 밖·RMW는 락 안), `rerun_enrichment`/`run_auto_search`(backlog #12), `collect_keyword_candidates`(M1), `_sync_submission_provider_state` 필드 단위 UPDATE 전환(M3). 패턴은 `mark_evidence_status`에 이미 있어 기계적 적용. 동시에 M4(seed 락 협소화) 검토. +6. **GUI 조작 신뢰성 배치** — H8(버튼 구현 또는 제거), H9(dismiss 서버 영속화 또는 최소 클라 필터), M16(엔드포인트 멱등화 + finally refresh), M22(공용 `withInFlightGuard` 헬퍼로 8개 mutation + setDecision(backlog #17) 일괄), M23(`derivedEntries || []`). 단일 프론트 PR. +7. **HTTP DoS 즉효 수정 + Phase 2 착수** — 음수/비정수 Content-Length 거부(M10, 2줄), 핸들러 `timeout` 설정(M11, 1줄)을 즉시. 이어서 백로그 Phase 2(items 6-11)를 보안 PR 하나로 — CSP/nosniff 헤더, 쿠키 분리, 토큰 엔트로피, `UrllibJsonTransport` 응답 캡. +8. **마이그레이션/보존 안전망** — M7(제약 재구축 시 coerce/격리 후 진행), M9(레거시 이관 시 파일 존재 검증 또는 prune 면제), M8(stem 충돌 시 확장자 포함 id), M6(ensure_queue 감사 이벤트). + +**핵심 메시지**: Phase 1은 제대로 수행됐지만, 이번 리뷰의 결론은 "함수 단위 수정에서 패턴 단위 수정으로 전환하라"이다. (a) 직렬화 필드 손실 1개 패턴이 HIGH 2건+MEDIUM 1건을, (b) 무락 RMW 1개 패턴이 HIGH 3건+MEDIUM 5건을 만들었다. 위 1·4·5번을 패턴 단위로 닫으면 신규 HIGH 9건 중 7건이 사라진다. \ No newline at end of file diff --git a/docs/reviews/2026-07-11-remediation-checklist.md b/docs/reviews/2026-07-11-remediation-checklist.md new file mode 100644 index 0000000..d4a4b3a --- /dev/null +++ b/docs/reviews/2026-07-11-remediation-checklist.md @@ -0,0 +1,107 @@ +# 2026-07-11 리뷰 리메디에이션 체크리스트 + +> 기준 리뷰: `docs/reviews/2026-07-11-project-review.md` (HEAD 89c1fe4) +> 신규 HIGH 9 + 유닛 추가 HIGH 3(통합/프론트) + MEDIUM ~28 + LOW ~14. +> 배치는 risk-per-effort 순. 각 배치 후 `pytest tests/rights_filter -q` 통과 확인. + +## Batch 1 — 스코어링 파이프라인 복구 (최우선) ✅ 완료 +직렬화 라운드트립 필드 손실 1개 패턴이 HIGH 3건을 만듦. 프로모터 3건은 배포 즉시 노출되므로 동일 배치. +- [x] H6 `store_serialization.py` — `_evidence_payload`에 `promoted`/`promotionReason` 추가, `_domain_evidence_from_ui`에서 복원 +- [x] H7 `store_serialization.py` — `entity`/`category` 추가+복원 +- [x] D1 `store_serialization.py` — 실제 `EvidenceSource` 영속화(`domainSource`), 키워드 휴리스틱은 레거시 폴백 +- [x] M12 `search_result_promoter.py` — no-results 구조적 매칭, 마커 스캔에서 query 텍스트 제외 +- [x] M13 `search_result_promoter.py` — 한국어 마커 추가, 단문 오탐 워드바운더리 +- [x] M14 `risk_scoring.py` — promoted Naver 결과 query_signature 기준 dedup +- [x] 회귀 테스트 7건 (라운드트립 score>0, 2 distinct entities, skip 무점수, dedup, 프로모터) + +## Batch 2 — 동시성 / _write_lock 규율 ✅ (M4/#12 제외) +무락 RMW 1개 패턴이 HIGH 3 + MEDIUM 5. +- [x] H1 `sqlite_store.py` — `record_decision` _write_lock +- [x] H2 `store_persistence.py` — `_apply_provider_usage_delta` 델타 병합(stale payload 되쓰기 금지) +- [x] H3 `store_enrichment.py` — `_sync_provider_payloads` 저장된 enabled 보존(`operatorDisabled` 플래그로 연결성/운영자 구분) +- [x] H4 `store_operations.py` — `manual_search` 네트워크 락 밖, RMW 재읽기 후 락 안 +- [x] M1 `store_operations.py` — `collect_keyword_candidates` 후보 메모리 누적→락 안 clear+insert +- [x] M2 `sqlite_store.py` — `set_provider_enabled`/`emergency_disable` 락+트랜잭션 +- [x] M3 `store_enrichment.py` — `_sync_submission_provider_state` 락 +- [x] 회귀 테스트 2건 (operator-disable 재시드 생존, usage delta가 in-flight disable 안 되돌림) +- [ ] #12 `rerun_enrichment`/`run_auto_search` 락 — **연기**(메가함수 내 네트워크+RMW 교차, 락 유지 시 가용성 회귀; 연기된 HIGH#6 리팩터와 함께) +- [ ] M4 seed 락 협소화 — **연기**(동일 이유, 가용성 최적화) + +## Batch 3 — 시드/저장소 견고성 ✅ +- [x] H5 `sqlite_store.py` — 빈 analysis run 시 'failed' 밴드+감사+continue + 테스트 +- [x] #13 `store_queue.py` — ensure_queue WHERE 키를 기존 행 id로 + 테스트 +- [x] M6 `store_queue.py` — 큐 활성/레거시 이관 시 감사 이벤트(스팸 방지 조건부) +- [x] M5 `http_app.py` — 업로드 TOCTOU: 배타 생성(`open xb`) 재시도 루프 +- [~] M8 `image_store.py` — **되돌림**: stem-충돌 접미어가 id를 형제-의존으로 만들어 판정된 submission 중복 부활 회귀(diff 리뷰 HIGH) → `id=path.stem` 복귀. 올바른 수정은 시드 단계 incumbent-id 보존 + 자산경로 마이그레이션 필요(연기) +- [ ] M7 `store_schema.py` — 제약 재구축 위반 행 coerce/격리 (미착수 — 마이그레이션 위험) +- [ ] M9 `store_queue.py` — 레거시 이관 파일 존재 검증 (연기 — 오이관 위험, 신중 설계) + +## Batch 4 — 통합/아웃바운드 ✅ +- [x] HIGH `cloud_vision_web_detection.py` — per-image error → HttpJsonError + 테스트 2건 +- [x] HIGH `search_policy.py` — daily_limit 날짜 윈도우 리셋 + Lock +- [x] M `google_custom_search.py` — map 응답을 try 안으로 +- [x] #9 `http_json.py` — 응답 크기 캡 + decode/read 예외 계약 +- [x] #10 API 키 URL→헤더 — **Vision만** `X-goog-api-key`(검증됨+테스트). customsearch는 헤더 미검증(미지원 시 검색 조용히 실패)이라 `key=` 쿼리 유지(diff 리뷰 MEDIUM) +- [x] #15 `store_enrichment.py` — lastFailure 첫 실패 아이템 (3곳) +- [x] LOW `store_remote_fetch.py` — SSRF `not is_global`(CGNAT 등 특수대역 차단) +- [x] LOW `env_clients.py` — NAVER display 3종 클램프(1~100) +- [x] M `env_clients.py` — daily-limit malformed → 보수적 100(fail-open 제거) + 테스트 갱신 +- [ ] M `naver_search.py` — 멀티페이지 쿼터 실제 요청 수 (미착수) +- [ ] M `store_remote_fetch.py` — SSRF DNS-rebinding TOCTOU (미착수 — 커스텀 opener 필요) +- [ ] LOW: EUC-KR 스니핑, dead code 삭제(레이아웃 테스트가 고정 → 유지) + +## Batch 5 — HTTP 보안 ✅ (M10/M11/#6) +- [x] M10 음수/비정수 Content-Length 거부 + 테스트 +- [x] M11 핸들러 timeout=30 +- [x] #6 앱셸 CSP/X-Frame-Options/nosniff + 테스트 +- [x] #8(부분) 토큰 길이<32 경고 + static 부재 경고(`__main__`) +- [ ] #7 쿠키 분리+Secure (연기 — Secure는 localhost HTTP서 쿠키 미전송→인증 붕괴; HTTPS 전제 필요) +- [ ] #8 rate limit (미착수) +- [ ] #10 Origin/Referer CSRF 폴백 (미착수) + +## Batch 6 — 프론트엔드 ✅ +- [x] H8 이미지 뷰어 버튼 구현 + 케이스별 리셋 +- [x] H9 dismiss 서버 영속화(신규 엔드포인트) + 테스트 +- [x] HIGH '자동 항목 비활성' 선택 케이스 스코프 +- [x] M16 deactivate 루프 멱등화(활성만, per-entry 실패 무시, finally refresh) +- [x] #17 setDecision in-flight 가드 + rerunSelected/applyCoverage null 가드 +- [x] M18 addKnowledgeEntry 수집탭 초기화 부작용 제거 +- [x] M19 executeSuggestedQueries refreshFromApi try/catch +- [x] M20 runCandidateCollection 선-클리어→성공 후로 +- [x] M22 apiError 배너 applyBootstrap에서 clear +- [x] M23 renderCorrections derivedEntries 가드 +- [x] #11(부분) riskClass 밴드 allowlist, case-image src safeUrl +- [ ] #11 잔여 sinks(similar-strip/face-crop safeUrl, innerHTML label) (미착수) +- [ ] #24 window.prompt → 인라인 (미착수 — 규모 큼) +- [ ] M21 8개 delegated mutation 공용 in-flight 가드 (미착수) + +## Batch 7 — env/config/docs ✅ +- [x] `env_file.py` UTF-8-sig BOM +- [x] `env_clients.py` daily-limit fail-open 제거 +- [x] `__main__`/launcher 경로 ROOT chdir + python 3.13 가드 + static/token 경고 +- [x] offline-install.md — Ollama/Playwright/lock 해시/egress 정정/`--only-binary` +- [x] .gitignore — .claude/settings.local.json, .impeccable/ +- [ ] `env_clients.py` LLM assistant OLLAMA 게이팅 (연기 — env={} llm 테스트 다수 의존) +- [ ] package.json 정리 (미착수) + +## Batch 8 — 에러핸들링/기타 ✅ (부분) +- [x] #14 `_sync_face_crops` 무음 예외 로깅 + preprocessing EXIF 폴백 경고(#19) +- [x] #16 skip 사유 reasons 오염 제거 + 테스트 +- [x] #18 dedup 핫패스 타깃 쿼리(2곳) +- [ ] #14 잔여 무음 swallow(page scrape 등) (미착수) +- [ ] #20 ScoreResult.reasons tuple (연기 — 테스트 다수 `== []` 단언 충돌) + +## Batch 7 — env/config/docs +- [ ] `env_file.py:12` — UTF-8-sig BOM +- [ ] daily-limit fail-open → 보수적 기본 +- [ ] `env_clients.py:239` — LLM assistant OLLAMA_* 게이팅 +- [ ] `__main__.py` — 경로 ROOT 앵커링 + static 부재 경고 +- [ ] offline-install.md — Ollama/Playwright/lock 해시 +- [ ] python 3.13 가드, .gitignore, package.json 정리 + +## Batch 8 — 에러핸들링/기타 백로그 +- [ ] #14 무음 예외 로깅 +- [ ] #16 skip 사유 reasons 오염 +- [ ] #19 EXIF/GPS PIL 폴백 경고 +- [ ] #20 ScoreResult.reasons tuple +- [ ] #18 dedup 핫패스 타깃 쿼리