0008: 근거 텍스트 경계 + instruction-like 패턴 무력화
0008: 근거 텍스트 경계 + instruction-like 패턴 무력화
- Status: accepted
- Date: 2026-05-11
- Deciders: hskim-solv
- Related:
rag_verifier.py(evidence_text_for_verification·neutralize_instruction_patterns·EVIDENCE_BOUNDARY— PR-J1 / issue #465 에서rag_core.py추출;rag_core는 backward-compat re-export 유지),scripts/llm_judge.py:_build_prompt, ADR 0003, ADR 0006, ADR 0028 (보완적 query-side defense)
TL;DR
- 근거 텍스트가 검증기/LLM 으로 들어가는 모든 지점에서
neutralize_instruction_patterns적용. - chat 토큰·role 태그·instruction-override 문구를 marker 로 wrap (silent strip 아님 — 인용 가독성 보존).
- 단일 helper + 단일
EVIDENCE_BOUNDARY상수 — call site 마다 별도 sanitizer 금지.
배경
이 repo 의 retrieved-evidence 텍스트는 외부 RFP 문서 출처, 두 downstream consumer 로 흐른다:
- deterministic 검증기 (
rag_core.py:evidence_text_for_verification, topic/entity 와 substring 매치) - LLM judge (
scripts/llm_judge.py:_build_prompt, chat 프롬프트에 직접 embed — ADR 0006)
메인 답변 생성기는 extractive(ADR 0003 — LLM 호출 없음), 전통적 “프롬프트 injection” 우회 안 됨. 그러나 chat template 토큰(<|im_start|>·<|im_end|>), role 태그(SYSTEM:·ASSISTANT:·USER:), instruction-override 문구(“Ignore previous instructions”)를 담은 악의적/실수 malformed RFP chunk 는 여전히:
- LLM judge 를 unsupported verdict 로 유도 → committable
agreement_with_verifieraggregate 오염 - 향후 도입될 LLM consumer(planner·generator) 영향
- trace·log·eval artifact 를 진짜 프롬프트 경계처럼 보이는 문자열로 오염
이 ADR 이전엔 근거 텍스트가 bare space 로 join 되어 그대로 전달. 적대 콘텐츠를 무력화할 단일 choke point 없음.
결정
rag_core.py 에 단일 helper neutralize_instruction_patterns(text: str) -> str 도입, document-controlled 텍스트가 검증기/LLM consumer 로 건너가는 모든 site 에 적용.
무력화 rule (모두 case-insensitive):
- Chat template 토큰
<|im_start|>·<|im_end|>·<|system|>·<|user|>·<|assistant|>·<|tool|>·<|begin_of_text|>·<|end_of_text|>·<|fim_*|>·<|endoftext|>→ 리터럴 sentinel[REDACTED_CHAT_TOKEN]으로 치환 - Role-tag 라인
^\s*(SYSTEM|ASSISTANT|USER|TOOL)\s*:\s*.+$매치(line start anchor, multiline) →[INSTRUCTION_LIKE]...[/INSTRUCTION_LIKE]wrap - Instruction-override 라인
^\s*(ignore|disregard|forget|override|bypass)\s+(previous|prior|all|any|the)\s+(instructions?|prompts?|rules?|directives?|system).*$매치 → 동일[INSTRUCTION_LIKE]marker wrap
적용 site:
evidence_text_for_verification(rag_core.py) —title·agency·project·section·text+ 각 메타데이터 값 무력화scripts/llm_judge.py:_build_prompt— chunk별 텍스트 무력화 + 공개 상수EVIDENCE_BOUNDARY = "\n[---EVIDENCE_BOUNDARY---]\n"로 chunk join;query·summary도 프롬프트 템플릿 포매팅 전 무력화
helper 와 boundary 상수는 public(leading underscore 없음) — 향후 다른 consumer 가 rule 재구현 없이 재사용 가능.
결과
Wins:
- LLM judge(ADR 0006) + 향후 LLM consumer 가 RFP-embedded chat 토큰/role 태그에 오도되지 않음
- 인용 가독성 유지 — 텍스트 콘텐츠는 verbatim 보존, marker 만 wrap
- 단일 지점 변경:
rag_core.py함수 1개 + call site 2개 갱신. API/답변 계약 bump 없음(ADR 0003 불변,schema_versionbump X) - 회귀 테스트(
tests/test_prompt_injection_regression.py)가 refactor 중 silent 제거 방지
Costs / 제약:
- “이전 지시사항 무시” 같은 문구를 정당하게 담은 regulatory RFP 섹션이
[INSTRUCTION_LIKE]marker 획득 — reader 는 marker 가 방어용이지 평가적 의미가 아님을 이해해야 evidence_text_for_verification출력에 marker 토큰([INSTRUCTION_LIKE]·[/INSTRUCTION_LIKE]·[REDACTED_CHAT_TOKEN]) 포함; 출력 exact string 동등성 단언 테스트는 이를 수용해야- 향후 기여자는 call site 별 parallel sanitizer 도입 대신
neutralize_instruction_patterns확장 필요
계약: 호출자는 위 marker 토큰을 콘텐츠 아닌 방어 주석으로 취급해야. topic 문자열이 marker 문법과 겹치지 않으므로 검증기 substring 매치 계약 영향 없음.
측정 갭
3 regex 패턴 + marker-token wrapping 은 exhaustive 방어 아님. issue #828 까지 ADR 이 현재 rule 미커버 공격 벡터를 표면화 안 함. 테스트에만 살지 않고 결정 레이어에 갭이 보이도록 여기 문서화:
- 오늘 방어 표면:
rag_verifier.py:262/266/269의 regex 셋; 공격별 positive 케이스는tests/test_prompt_injection_regression.py+ 벡터별 적대 pinningtests/test_evidence_boundary_attack_vectors.py - Marker-bypass / marker-tag confusion — issue #830 surgical PR 로 closed:
rag_verifier.py의_LITERAL_MARKER_RE가 입력의 리터럴[INSTRUCTION_LIKE]/[/INSTRUCTION_LIKE]토큰을 wrap 적용 BEFORE[INPUT_MARKER]로 재작성 — 출력의 모든 marker 토큰은 공격자가 아닌 방어가 작성 - 잔여 미해결 공격 벡터 (tracking #830):
- Chat-token aliasing: 주위 whitespace(
< |im_start| >), fullwidth lookalike(<|im_start|>), 부분 매치(<|im_star)는 정규화 안 됨.tests/test_evidence_boundary_attack_vectors.py::TestVector3ChatTokenAliasingpinning. 수정 경로: regex 매치 전 NFKC unicode 정규화 - Role-tag case (fullwidth only): ASCII
IGNORECASE가SyStEm:처리; 갭은 fullwidth(SYSTEM:).TestVector4RoleTagCaseUnicode::test_4b_fullwidth_role_tag_NOT_defendedpinning. 수정 경로: chat-token aliasing 과 동일 NFKC 정규화 - Instruction-override paraphrase: regex 가
ignore/disregard같은 키워드 요구; 의미 paraphrase(“reset everything we discussed”)는 통과.TestVector5InstructionOverrideParaphrasepinning. ADR Alternatives 섹션 따라 deterministic 검증기 범위 외 — LLM classifier 필요
- Chat-token aliasing: 주위 whitespace(
- regex 변경 결정 규칙: 새 rule(또는 완화)은 ≥1 신규 공격 벡터 커버 + real-corpus survey set 에 false-positive 회귀 없음(한국어 법률 언어가 regulation-quote 맥락에서 “이전 지시사항 무시” 정당 인용 빈번)
- 적대 corpus 확장 계획: #830 에서 커버(기존 회귀 set 출발, 위 공격 벡터별 확장)
- Cross-link: LLM judge(ADR 0006)는 자체 evaluation 표면 보유; 방어 회귀는 리더보드
agreement_with_verifierdelta 로 표면화. #830 출하 후 적대 확장 벤치마크 거기 추가
검토한 대안
- 패턴 silent strip — Reject: 근거 파괴 + 인용 audit 가능성 깨짐. 인용 reader 는 source 에 있던 것을 봐야
- pluggable rule 의 신규
Sanitizer클래스 — Reject: CLAUDE.md “Reuse over invent”. 두 번째 consumer 가 다른 rule 필요해질 때까지 한 함수로 충분 - LLM judge 경계에서만 sanitize — Reject: defense-in-depth, 검증기가 이미
evidence_text_for_verification호출 — 단일 함수가 자연스러운 choke point - 시스템이 extractive 라 변경 스킵 — Reject: ADR 0006 LLM judge 가 이미 근거 텍스트 소비, 향후 generative 파이프라인 명시 예상