leerness 1.36.95 → 1.36.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.96 — 2026-07-31 — 실행기를 만들지 않고 문서를 띄운다 (P-0010)
4
+
5
+ 사용자 요청: *"leerness.html 대신 exe 같은 실행기로 로드맵·그래프·스킬·기술·핸드오프·플랜 마크다운을 그대로 띄울 수 있나? 성능에 영향을 준다면 leerness.html 로 유지하고 거기에 반영."*
6
+
7
+ **실행기는 만들지 않았다.** exe/Electron 은 런타임 의존성 0 · install script 0 · 사용자 저장소에 실행기 파일 미생성이라는 경계를 깬다 — 그 경계는 1.36.90 에서 테스트로 못박아 뒀다. 사용자가 준 조건("성능에 영향을 준다면 leerness.html 에 반영")이 그대로 답이었다. 같은 목적을 **정적 단일 파일 안에서** 이룬다.
8
+
9
+ **📄 문서 탭** — `.harness` 핵심 문서 7종(플랜·스킬·기술 프로필·세션 핸드오프·현재 상태·진행 추적·결정 기록)을 `leerness.html` 안에서 그대로 읽는다. 좌측 목록 → 우측 본문, 브라우저만 있으면 된다.
10
+
11
+ - **렌더러는 최소 부분집합만 해석한다** — 제목(H1~H6) · **단층** 목록(중첩은 평탄화) · 순서 목록(**원문 번호 보존** — 시작값과 건너뛴 번호를 `ol start`·`li value` 로) · 코드펜스 · 표(**정렬 반영**) · 링크 · 강조. 나머지는 **이스케이프된 평문**이다(예외: 자리표시자와 충돌하는 U+0001·U+0002 는 표시 전에 제거한다). 이 파일은 사용자 문서를 그대로 싣기 때문에 파서를 넓힐수록 XSS 면이 넓어진다. 원시 HTML(`<script>`, `<img onerror>`)은 글자로 남고, 링크는 `http(s)`/`mailto`/앵커/상대경로만 통과한다(`javascript:`·`data:`·`vbscript:`·프로토콜 상대 `//` 거부).
12
+ - **자격증명 표지가 있는 문서는 본문을 아예 싣지 않는다.** 값 단위 마스킹을 다섯 번 고쳤고 매번 새 누출 경로가 나왔다(아래) — 그래서 **마스킹을 안전의 전제에서 뺐다**. 표지는 값이 아니라 **형식**으로만 정의해 줄 앞 접두어(`> `·`- `·들여쓰기)·대소문자·base64 조성·placeholder 모양에 의존하지 않는다. 마스킹(`redactSecrets`, 대시보드와 같은 술어)은 표지가 없는 문서에 대한 **두 번째 방어층**으로만 남는다.
13
+ - 비용을 먼저 쟀다 — 실제 프로젝트 문서 **156개 중 표지가 걸린 파일 1개(0.6%)**. 사실상 공짜라서 채택했다.
14
+ - 검증: 알려진 경로 **14종**(원본/인용문/목록/들여쓰기/CRLF/END없음 PEM 6 · `~`·`+`·`/` Bearer 3 · 소문자 `bearer` 1 · `AUTH=` 뒤 토큰 1 · placeholder 모양 1 · 반복 문자 1 · DB 접속문자열 1) 전부 본문 미포함. 이후 검수가 보인 **네 형태**(RFC6750 짧은 Bearer · 쿠키 · compact JWE · 강조로 감싼 스킴)도 같은 술어에서 검출된다. 표지 없는 문서는 그대로 실린다.
15
+ - **상한과 고지**: 문서당 64KB · 전체 512KB(**UTF-8 바이트**). 잘리면 `x/y자(뒷부분만)` 로 적는다. 전체 상한이 남은 경우 **잔여 예산만큼 담고**, 잔여가 의미 없는 크기(전체 상한의 1/8, 최대 2KB)면 조각 대신 "담지 않았다"고 고지한다. (기본 7종에서는 7×64KB=448KB 라 전체 상한이 걸리지 않는다 — 목록이 늘 때를 위한 backstop 이고, 상한을 주입해 분기를 실제로 발화시켜 시험한다.) 누적 로그(진행 추적·결정 기록)는 **뒤**를 남기고 그 외는 앞을 남긴다 — 1.36.95 handoff 에서 고친 그 방향 규칙을 그대로 따른다.
16
+ - 실측(이 저장소): 문서 탭이 `leerness.html` 에 더하는 양은 **+39.2KB**(120.8KB → 159.9KB). 자동 생성 기본값은 바꾸지 않았다.
17
+
18
+ **만들면서 세 번 틀렸고, 셋 다 다른 방식으로 드러났다.**
19
+
20
+ 1. **코드스팬 자리표시자가 본문의 모든 숫자를 삼켰다.** 자리표시자를 감싸는 문자를 빠뜨려 복원 정규식이 `/(\d+)/g` 가 됐고, `1.36.96` 같은 평범한 숫자가 코드로 바뀌었다. 게다가 그 문자를 **원시 제어문자(U+0001)로** 소스에 심어 놓아 눈에 보이지도 않았다 — 명시적 ``\u0001`` 이스케이프로 바꿨다.
21
+ 2. **전체 상한 512KB 가 구조적으로 도달 불가능했다.** 7종 × 64KB = 448KB 라 `skipped` 분기가 한 번도 실행되지 않았고, 그걸 검사하던 `every` 는 빈 컬렉션 위에서 공허하게 참이었다. 상한값을 주입 가능하게 해 분기를 실제로 발화시켰다(측정: skipped 3건). 그 과정에서 **159자짜리 쓸모없는 조각**이 "잘림"으로 표시되던 것도 발견해, 잔여 예산이 의미 없으면 조각 대신 "담지 않았다"로 바꿨다.
22
+ 3. **좁은 화면에서 레이아웃이 무너졌다** — 190px 고정 내비가 자리를 다 먹어 본문이 한 글자씩 세로로 흘렀다. 테스트로는 보이지 않는 종류라 **실제 브라우저로 열어 봐야** 잡혔다. 760px 이하에서 내비를 가로 칩으로 접는다(실측: 375px 에서 본문 폭 90 → 323px, 가로 스크롤 0).
23
+
24
+ **그리고 검수가 출하를 막았다 — 10건 중 7건이 재현됐고 전부 고쳤다.**
25
+
26
+ - **P1 시크릿 누출 — 마스킹이 스캐너보다 약한 목록을 쓰고 있었다.** DB 접속문자열 비밀번호 · GitLab PAT · npm 토큰 · Stripe live 키 · SendGrid · 개인키가 **그대로 화면에 실렸다**(실측 6종). 전부 `catalogs.js` 의 `SECRET_PATTERNS` 에 **이미 있던** 패턴이다 — 한쪽 표면만 강화된 상태로 방치돼 있었다. 같은 목록을 공유하게 하고(placeholder·엔트로피 가드도 스캐너와 동일), 개인키는 BEGIN 줄만이 아니라 **블록 전체**를 지운다(종전엔 본문 base64 가 남았다). `leerness.html` 은 공유·첨부되는 파일이라 결과가 더 나빴다. 이 수정은 대시보드에도 함께 적용된다.
27
+ - **P2 빈 경계 줄 하나가 문서 전체를 지웠다.** 선두 빈 줄 + 초장문 한 줄이면 `keep` 은 1 이 되는데 담긴 내용은 0자였다 — **70,000자 문서가 빈 화면**이 됐다(누적 로그는 끝 개행 하나로 같은 일이 났다). 줄 수가 아니라 담긴 **내용**으로 판정하고, 없으면 경계의 내용 있는 줄을 코드포인트로 자른다.
28
+ - **P2 "64KB/512KB" 가 사실이 아니었다.** UTF-16 코드 단위를 세면서 KB 라고 적어, 한글 문서에서 합계 **1,152KB**(HTML 1.21MB)까지 실렸다. 상한을 UTF-8 바이트로 바꿔 주장을 참으로 만들었다.
29
+ - **P2 링크 정규식이 O(n²) 였다.** 닫히지 않은 대괄호마다 남은 문자열 전체를 다시 훑어 64KB 입력에서 **1,825ms**(최악 2,730ms) 브라우저 메인 스레드가 멈췄다. 수량자를 유계로 바꿔 **41ms**(최악 23ms).
30
+ - **P2 역슬래시 프로토콜 상대 URL 이 통과했다.** 슬래시 2개만 막았는데 브라우저는 역슬래시를 슬래시처럼 읽는다 — `\\host\share` 가 링크가 됐다.
31
+ - **P2 e2e 가 계약을 깨는 변이 3종을 통과시켰다.** 허용 링크는 HTTPS 하나만, 제목은 H2 만 검사했고, `tech`·`progress` 두 문서는 아무것도 확인하지 않아 경로가 끊겨도 초록이었다.
32
+ - **P3 링크를 만든 뒤 강조 정규식이 href 까지 다시 해석했다** — `href="https://a/<b>b</b>/c"`. 완성된 앵커를 자리표시자로 빼 둔다. 문서에 실제로 U+0001 이 있으면 자리표시자와 충돌하던 것도 함께 막았다.
33
+ - **검수가 틀린 것도 있었다.** SendGrid "누출"은 세 번 연속 **내 픽스처**가 틀린 것이었고(뒤가 38자 → 같은 글자 반복 → 39자) 코드는 처음부터 정상이었다. 길이를 손으로 세지 말고 생성하도록 바꿨다. 마스킹 오탐 1건(`password = 사용자가…`)도 1.36.95 규칙만으로 재현해 **회귀가 아님**을 확인했다.
34
+
35
+ **그리고 재검수가 다시 막았다 — 6건 중 3건이 위 수정이 만든 새 결함이었다.**
36
+
37
+ - **P1 END 줄이 없는 개인키는 본문이 그대로 남았다.** "블록 전체를 지운다"는 규칙이 `-----END …-----` 를 요구했는데, 붙여넣다 잘린 키가 오히려 흔하다. BEGIN 뒤에 이어지는 base64/헤더 줄까지 함께 지운다.
38
+ - **P2 빈 경계 줄이 두 개면 같은 데이터 소실이 재발했다.** 내가 "담긴 내용으로 판정"한다며 쓴 `acc` 에는 **줄바꿈 바이트가 포함**돼 있었다 — 빈 줄 두 개면 `acc=1` 이 되어 판정을 통과하고 70,000자가 줄바꿈 한 글자로 줄었다. 내용 바이트를 따로 센다. 픽스처를 빈 줄 **한 개**로만 만든 탓에 못 봤다.
39
+ - **P2 스캐너 목록을 그대로 공유한 것이 정상 산문을 파괴했다.** 그 목록에는 `AWS secret …` · `Bearer …` 같은 **탐지 전용 문맥 휴리스틱**이 섞여 있다. 탐지에서 오탐은 경고 한 줄이지만, **가림에서 오탐은 사용자가 보는 문서를 조용히 훼손**한다(대시보드도 같은 함수를 쓴다). 실측: `결정: AWS secret commit <해시> was reverted` → `결정: *** was reverted`. 엔트로피 게이트로는 갈리지 않았다(진짜 AWS 키까지 placeholder 로 판정된다). 문맥 패턴은 가림에서 제외하고, Bearer 는 값에 숫자가 있을 때만 가린다. 남는 구멍(`Authorization:` 없이 적힌 영문자만의 Bearer 토큰)은 명시해 둔다.
40
+ - P3 링크 길이 상한이 **이스케이프 후** 길이에 걸려 원문 796자짜리 정상 쿼리 URL 이 거부됐다(`&`→`&amp;` 팽창). 실측 비교로 라벨 300 / URL 4,000 을 골랐다 — 최악 115ms(무계는 1,747ms).
41
+ - P3 잘림 고지의 "자" 가 UTF-16 코드 단위였다 — 이모지 문서에서 `32,767/60,001자` 로 표시되지만 실제는 `16,384/30,001자`. 코드포인트로 센다.
42
+ - **내 실측이 "신규 오탐 0" 이라고 말했는데도 오탐이 실재했다.** 실제 문서 189개에 그런 문장이 없었을 뿐이다 — **실제 데이터 측정은 구성된 반례를 대체하지 못한다.**
43
+
44
+ **세 번째 검수에서 7건이 더 나왔고, 같은 자리에서 세 번 틀린 것이 있었다.**
45
+
46
+ - **P1 암호화 개인키의 본문이 샜다.** 암호화 키는 `Proc-Type:`/`DEK-Info:` 헤더 뒤 **빈 줄**이 오고 본문이 시작하는데, 규칙이 빈 줄에서 멈췄다. 반대로 아무 `Name: value` 줄이나 먹으면 BEGIN 을 인용한 정책 문서의 산문을 삼킨다 — 헤더는 **PEM 표준 이름만** 허용하고 본문 base64 줄이 최소 하나 있을 때만 확장한다.
47
+ - **P1 무관한 산문을 통째로 삼키는 경로가 있었다(변이 실험 중 발견).** `[\s\S]*?` 로 BEGIN 과 END 를 잇는 규칙은 그 사이의 **아무 내용이나** 먹는다 — 종료되지 않은 키 하나가 문서 뒤쪽의 END 줄과 짝지어지면 사이의 모든 문단이 사라진다. 사이에는 PEM 헤더·base64·빈 줄만 허용하도록 좁혔다.
48
+ - **P2 영문자만으로 된 32자 Bearer 토큰과, 구분자 없는 AWS 실값이 샜다.** 앞 라운드에서 오탐을 막으려고 건 조건이 실값까지 놓쳤다. Bearer 는 "숫자가 있거나 32자 이상", AWS 문맥은 패턴을 되살리되 **꼬리 값만** 치환한다(문장 구조는 남는다 — 커밋 해시가 함께 가려지는 것은 감수한다. 누출보다 낫다).
49
+ - **P2 데이터 소실이 세 번째로 재발했다 — CRLF 의 `\r`, 공백, 탭이 "내용"으로 세어졌다.** `keep > 0` → `acc > 0` → `content > 0` 으로 세 번 고쳤고 매번 셈을 조금씩 틀렸다. **같은 자리에서 세 번 틀렸다면 픽스처가 아니라 접근이 틀린 것이다.** 셈을 없앴다 — 줄 단위로 담은 것과 경계 줄을 코드포인트로 자른 것을 **둘 다 만들어 긴 쪽**을 쓴다. 어떤 공백 조합에도 예산을 채우는 쪽이 이긴다.
50
+ - **P2 URL 스킴 판정이 C0 제어문자로 우회됐다.** 탭·개행만 지웠는데 `\x03`·`\x01`·`\x00`·`\x1F`·`\x7F` 를 앞이나 중간에 끼우면 통과했다. C0 전체와 DEL 을 제거한다.
51
+ **네 번째 검수에서 또 7건 — 그리고 "A를 고치면 B가 깨지는" 형태가 두 번 나왔다.**
52
+
53
+ - **P1 20~31자 영문 Bearer 토큰이 샜다.** 앞 라운드에서 오탐을 막으려 건 길이 문턱(32자)이 실제 토큰을 놓쳤다 — OAuth 토큰 길이는 보장되지 않는다. **길이가 아니라 모양**으로 가른다: 낱말을 이어 붙인 camelCase(각 조각 3자 이상)만 산문으로 보고 나머지는 가린다. `authenticationMiddleware` 는 조각이 14/10자라 산문, `AbCdEfGhIj…` 는 조각이 2자라 토큰이다.
54
+ - **P2 절단이 문서의 제목·푸터를 버렸다.** "줄 단위 결과와 코드포인트 절단 결과 중 긴 쪽" 이라는 앞 라운드의 수정이, 경계 줄이 길면 **앞부분을 통째로 버렸다** — `TITLE` + 초장문이면 `TITLE` 이 사라지고 화면 고지의 "앞부분만" 이 거짓이 된다. 온전한 줄로 채운 뒤 **남은 예산만큼 경계 줄을 이어 붙인다**. 그런데 그렇게만 하면 이번엔 평범한 문서까지 줄 중간에서 잘렸다 — **혼자서는 어떤 예산으로도 담길 수 없는 줄**일 때만 이어 붙이도록 좁혀 두 성질을 함께 만족시켰다.
55
+ - **P2 BEGIN 인용 뒤의 평범한 영문 한 줄을 개인키 본문으로 오인해 지웠다.** 본문 요건이 16자였다. 40자로 올렸더니 이번엔 **줄이 짧게 감긴 키의 본문이 샜다**(내 회귀 확인에서 22자·16자 본문이 남았다). **길이가 아니라 내용**으로 가른다 — 진짜 base64 는 숫자나 `+ / =` 를 포함하고, 영문 낱말을 이어 붙인 식별자는 포함하지 않는다.
56
+ - P3 PGP 블록은 END 앞 `=CRC` 줄을 허용하지 않아 CRC 와 END 가 남았다.
57
+ - **두 번 다 "더 센 조건" 이 아니라 판별 기준 자체를 바꾸는 것이 답이었다.** 길이 문턱을 올렸다 내렸다 하면 한쪽이 반드시 새거나 파괴된다.
58
+
59
+ **다섯 번째 검수가 그 "판별 기준"마저 양방향으로 깼다 — 그래서 원칙을 정했다.**
60
+
61
+ - **P1 실제 RSA 키의 영문자 전용 본문 줄에서 51줄이 그대로 노출됐다.** 검수가 진짜 키를 생성해 16자로 감싸니 영문자만인 줄이 나왔고, "진짜 base64 는 숫자나 `+ / =` 를 포함한다"는 내 판별이 무너졌다. 반대 방향도 함께 무너졌다 — `PolicyVersion2026Final` 은 숫자를 포함한 22자 영숫자라 본문으로 분류돼 문서가 훼손됐다.
62
+ - **P1 `Bearer abcdefghijklmnopqrst` · `Bearer CorrectHorseBatteryStaple` 같은 유효한 불투명 토큰이 산문으로 판정돼 노출됐다.** 낱말꼴 예외를 둔 순간 **그 예외가 곧 누출 경로**가 됐다.
63
+ - **다섯 번 시도해 매번 한쪽이 깨졌다면, 그건 판별의 문제가 아니라 원칙의 문제다.** 값의 조성으로 시크릿과 산문을 가르려는 시도에는 반드시 반례가 있다. 그래서 원칙을 정했다 — **표시용 마스킹은 안전한 쪽으로 실패한다.** 값이 아니라 **문맥**으로 판정한다: `BEGIN` 마커 뒤의 **줄 전체에 공백이 없는 줄**은 키 재료로 본다. 산문은 거의 항상 공백을 포함하므로 문단은 살아남고, 어떤 조성의 키 본문도 새지 않는다. Bearer 도 예외를 없앴다.
64
+ - **대가는 명시한다.** BEGIN 을 인용한 문서에서 **바로 뒤의 단일 토큰 한 줄**이 가려지고, `Use Bearer authenticationMiddleware for API requests.` 는 `Use *** for API requests.` 가 된다. 원본 파일은 그대로이고 이건 표시용 스냅샷이므로, 누출보다 이쪽이 낫다. (20자 미만은 카탈로그가 후보로 잡지 않으므로 `Bearer 인증` 같은 표현은 영향이 없다.)
65
+ - 검증: 진짜 RSA 키를 생성해 16자·64자 두 방식으로 감싸고 END 유무까지 조합해 **본문 0줄 누출**을 확인했다.
66
+
67
+ **여섯 번째 검수가 그 원칙마저 뚫었다 — 그래서 마스킹을 안전의 전제에서 뺐다.**
68
+
69
+ - **P1 Markdown 인용문(`> `)으로 감싼 실제 개인키가 전량 노출됐다.** 내 규칙은 줄 앞에 공백·탭만 허용했다. README·정책 문서에서 키를 blockquote 로 인용하는 것은 자연스러운 마크다운이다. 16자 래핑에서 51/51, 64자에서 13/13 줄이 그대로 실렸다.
70
+ - **P1 Bearer "예외 없음" 원칙에도 누출 경로가 넷 남아 있었다** — 카탈로그가 대문자 `Bearer` 만 받고 `~`·`+`·`/` 를 문자군에서 빠뜨렸으며, `AUTH=Bearer <토큰>` 은 앞부분만 가려 `AUTH=*** <실토큰>` 이라는 **거짓 안심**을 만들었고, placeholder 모양 토큰은 가드가 통과시켰다.
71
+ - **여섯 번을 고쳤는데도 새 경로가 나온다면, 고치는 방향이 틀린 것이다.** 값 단위 마스킹이 완벽해야 안전한 구조를 버렸다 — **자격증명 표지가 하나라도 있으면 그 문서의 본문을 싣지 않는다.** 이제 마스킹이 부분적으로 실패해도 누출로 이어지지 않는다.
72
+ - **변이가 5건 살아남아 픽스처의 결함도 드러났다** — 시크릿을 한 파일에 몰아넣으면 **표지끼리 서로를 가려 줘서**, 특정 표지를 무력화하는 변이가 그대로 통과했다. 표지 술어는 **하나씩 격리**해 단언한다(7/7 검출). 남은 생존 2건은 `_SECRET_BEARER` 가 먼저 가려 등가가 된 것이라 목록에서 뺐고, 마스킹 제거 변이는 "가드를 통과한 문서에도 두 번째 층이 살아 있는가" 단언을 넣어 잡았다.
73
+
74
+ **일곱 번째 검수가 가장 아픈 것을 짚었다 — 내가 표면 하나만 보고 안전을 선언했다.**
75
+
76
+ - **P1 문서 탭을 가드해도 같은 내용이 `nodes`·`roadmap` 으로 그대로 나갔다.** `decision add "Authorization: Bearer <토큰>"` 이 `DATA.nodes[].label` 과 `detail.decision` 에 원문으로 실렸다(실측). 그래프 쪽에는 **마스킹조차 없었다.** "문서 본문을 통째로 제외하므로 마스킹 실패가 누출로 이어지지 않는다"는 내 전제가 성립하지 않았다.
77
+ - 필드마다 손으로 감싸면 반드시 빠뜨린다(그래서 문서 탭만 덮였다). **임베드 직전 한 곳**에서 모든 문자열을 같은 술어로 통과시킨다 — 지금 있는 표면도, 나중에 추가될 표면도 함께 덮인다.
78
+ - 실측: decision·task·plan 세 경로 모두 생성 HTML 과 `DATA` 전 경로에서 0건. 정상 항목은 그래프에 그대로 남는다.
79
+ - **P1 표지를 빠져나가는 네 형태** — RFC6750 의 짧은(19자) Bearer, 세션/Set-Cookie, compact JWE(5분절이라 `eyJ…eyJ` 가 아니다), 마크다운 강조로 감싼 `` `Bearer` ``. 넓히기 전에 비용을 쟀다: 실제 문서 156개에서 **추가로 걸리는 파일 0개**라 셋 다 넓혔다.
80
+ **여덟 번째 검수에서 P1 이 처음으로 0 이 됐고, 남은 P2 는 내가 방금 넣은 코드가 원인이었다.**
81
+
82
+ - **깊이 처리를 세 번 바꿨고, 두 번째 진단이 틀렸다.** ① 상한 12 + **원본 통과** → 12단계 밖이 정규화를 우회(누출). ② "상한이 우회를 만든다"고 보고 **상한을 제거** → 약 24KB 짜리 유효 JSON 하나로 **생성 자체가 실패**(직렬화가 4천 단계에서 스택을 터뜨린다). ③ 상한 200 + **자리표시자 대체** → 8천 단계까지 생성 성공, 누출 0. **문제는 상한이 아니라 상한을 넘겼을 때 원본을 통과시킨 것이었다 — fail-open 이면 우회, fail-safe 면 보호다.**
83
+ - **순환을 막으려 쓴 방문 집합이 정상 데이터를 지웠다.** 전역 집합으로 두면 같은 객체를 두 필드가 공유할 때 두 번째가 순환이 아닌데도 `null` 이 된다(실측). 재귀 스택처럼 **들어갈 때 넣고 나올 때 빼야** 진짜 순환만 끊긴다.
84
+ - **CRLF 문서를 앞에서 자를 때 고립된 `\r` 이 남았다** — `\n` 으로만 나누므로 줄 끝의 `\r` 이 내용으로 세어지고, 재조립 때 개행이 붙지 않으면 제어문자가 표시 데이터에 남는다.
85
+ - **순서 목록 번호가 조용히 바뀌었다** — `2. two` 가 브라우저에서 `1. two` 로, `1·3` 이 `1·2` 로 보였다. 문서의 의미를 바꾸는 것이라 원문 번호를 보존한다. **표 정렬**도 문법으로는 받으면서 의미를 버리고 있었다 — 받은 이상 반영한다.
86
+ - **변이 진단에서 픽스처가 판별점을 비껴간 경우가 셋 더** — CRLF 는 경계 줄이 이어붙어 정상 CRLF 가 되고(상한 안에 들어가는 줄로 교체), 순환은 깊이 상한이 먼저 끊어 주며(출력 크기까지 단언), 깊이 상한은 "생성 성공"만 보면 낮춰도 통과한다(보통 깊이 데이터 보존까지 단언).
87
+
88
+ - **변이 진단에서 픽스처 함정이 둘 더 나왔다** — 누출 needle 을 `Authorization: Bearer …` 로 만들었더니 **마스킹만으로도 지워져** 표지 가드 제거 변이가 살아남았고(쿠키 형태로 교체), Bearer 문자군 판별 픽스처는 `/`·`~` 를 맨 앞에 둬서 **접두 허용이 흡수**해 버렸다(가운데 배치 + 양쪽 16자 미만으로 교체). 둘 다 가드가 약한 게 아니라 픽스처가 그 경로를 판별하지 못한 경우였다.
89
+
90
+ - **변이 생존 5건의 원인이 세 갈래로 갈렸다** — 등가 변이 1건(가림 제외 목록이 비어 분기가 무의미), **픽스처 간섭** 3건(한 문서에 PEM 을 몰아넣어 서로를 가려 줬다 → 마스킹 단언을 격리 입력으로 직접 검증), **픽스처가 분기에 도달하지 못한** 1건(산문 뒤 base64 가 없어 규칙이 발동조차 안 했다). 앵커가 유일하지 않아 엉뚱한 규칙을 고치던 것도 유일성 검사가 잡았다.
91
+
92
+ - **마스킹을 넓혔으니 실제 데이터로 오탐을 쟀다.** 로컬의 실제 leerness 프로젝트 문서 **189개 · 10,431줄**에 걸어 본 결과, 마스킹된 72줄은 **전부 1.36.95 규칙으로도 이미 마스킹되던 줄**이었다 — 이번 변경이 새로 건드린 줄은 **0건**, 반대로 커버리지가 후퇴한 줄도 **0건**. (그 72줄에는 `idempotency_key=run:phase:step` 같은 **기존 규칙의 오탐**이 섞여 있다. 이번 회귀가 아니므로 별도 항목으로 남겼다.)
93
+ - 검증: 게이트 exit 0 · e2e **416/416** · 신규 1블록 · 변이 **81종 81/81**(등가 2건 제외 · 앵커 문제 0) · 자체 헌트 24종 결함 0 · 출구 전수 13종 · 계약 25종(원시 HTML · 엔티티/제로폭 URL · 표 구분줄 오인 · 미종료 펜스 · 주석 · CDATA) · 성능 6종. 실제 브라우저(788px·375px)에서 탭 전환·7종 내비·본문 렌더 확인.
94
+ - **변이 1차에서 4건이 살아남았고 원인이 넷 다 달랐다** — 변이 하네스의 앵커가 유일하지 않아 엉뚱한 함수를 고친 것(하네스 버그), `mdInline` 도 내부에서 이스케이프하므로 XSS 단언만으론 못 잡는 것(단언이 목적이 아니라 부작용만 봄), 이모지만 반복한 픽스처가 서로게이트 쌍 경계에 정확히 맞아 쪼갤 자리가 없던 것, 줄 경계 단언이 tail 문서에만 걸려 non-tail 변이를 못 본 것. 앵커 유일성 검사를 러너에 넣고 나머지 셋은 픽스처를 고쳤다.
95
+ - **계약 검증 도구 자신이 오탐 5건을 냈다.** 출력 전체에서 `on...=` 를 찾아 `&lt;img src=x onerror=1&gt;` 처럼 **이미 이스케이프된 글자**를 속성으로 오인했다. 그대로 믿었으면 없는 버그를 고쳤을 것이다. 실제 태그 안만 보도록 고치고, **탐지기가 진짜 위반을 잡는지 대조군으로 먼저 증명**한 뒤 "위반 0"을 말한다.
96
+ - **게이트 실패 한 번은 내가 만들었다.** 게이트 실행 중에 소스를 고쳤고, 그 편집이 `String.raw` 템플릿 안 주석에 백틱을 넣어 템플릿을 끊었다 — 바로 그 위에 "여기선 백틱을 쓸 수 없다"고 적어 둔 자리였다. 처음엔 동시 실행 간섭이라고 생각했지만 격리 재현으로 5초 만에 진짜 원인이 나왔다.
97
+ - **검수가 살려낸 마지막 변이는 "데이터는 맞는데 화면이 틀리는" 경우였다.** 코드포인트 단언을 데이터에만 걸어 둬서, 화면 고지를 `String.length` 로 되돌리는 변이가 통과했다. 사용자가 보는 것은 화면이다 — 생성물에서 고지가 어느 값을 쓰는지까지 확인한다.
98
+
3
99
  ## 1.36.95 — 2026-07-31 — AI 에게 매 세션 읽히던 파일의 절반이 틀린 과거 수치였다
4
100
 
5
101
  사용자 질문에서 시작했다 — "Opus 5 처럼 모델에 검증이 내장돼 있으면 하네스가 검증을 또 시키는 건 낭비 아닌가. 구동 모델을 인지해 맞춤으로 알려줄 수 있나?" 조사 결과 **모델 인지는 만들지 않기로 했고**(아래), 대신 그 조사가 훨씬 큰 것을 드러냈다.
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.95 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.96 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.95는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.96는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.95는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.95 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.96 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.95: 2026-07-31
238
+ Last synced by Leerness v1.36.96: 2026-08-04
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.95';
37
+ const VERSION = '1.36.96';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
package/lib/graph.js CHANGED
@@ -8,6 +8,11 @@
8
8
  'use strict';
9
9
  const path = require('path');
10
10
  const { absRoot, exists, read, writeUtf8, log } = require('./io');
11
+ // 1.36.96 (P-0010): 📄 문서 탭의 시크릿 마스킹은 dashboard 와 **같은 술어**를 쓴다.
12
+ // 표면마다 다시 구현하면 한쪽만 고쳐지는 불일치가 생긴다(1.36.88→1.36.90 에서 이미 겪은 클래스).
13
+ const _redact = require('./pure-utils').redactSecrets;
14
+ // 1.36.96: 마스킹이 **완벽해야 하는 구조를 버렸다** — 자격증명 표지가 있는 문서는 본문을 싣지 않는다.
15
+ const _hasCred = require('./pure-utils').hasCredentialMarker;
11
16
 
12
17
  // 검증된 프로토타입 템플릿(Claude Preview 렌더+클릭조회 확인). `/*__DATA__*/null` 자리표시자에 JSON 주입.
13
18
  // String.raw 필수: 내부 JS 의 `\'` 같은 escape 가 원문 그대로 출력돼 브라우저 JS 엔진이 해석하도록 보존.
@@ -62,10 +67,36 @@ canvas{position:fixed;inset:0;top:46px}
62
67
  .cmd.show{display:flex;align-items:center;gap:10px}
63
68
  .cmd button{background:#1a2230;border:1px solid var(--line);color:var(--txt);border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:10px}
64
69
  .note{color:var(--mut);font-size:11px;margin:14px 0 18px;line-height:1.6}
70
+ /* 📄 문서 탭 (1.36.96, P-0010) */
71
+ .dwrap{display:flex;gap:16px;align-items:flex-start}
72
+ .dnav{flex:0 0 190px;position:sticky;top:70px}
73
+ .dnav div{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--mut);user-select:none}
74
+ .dnav div.on{background:#1a2230;color:#fff}
75
+ .dbody{flex:1;min-width:0;border:1px solid var(--line);border-radius:10px;padding:16px 22px;background:var(--panel)}
76
+ .md h1,.md h2,.md h3,.md h4,.md h5,.md h6{color:#fff;line-height:1.35;margin:18px 0 10px}
77
+ .md h1{font-size:20px}.md h2{font-size:17px}.md h3{font-size:15px}.md h4,.md h5,.md h6{font-size:13px}
78
+ .md p{margin:0 0 10px;line-height:1.7;word-break:break-word}
79
+ .md ul,.md ol{margin:0 0 10px;padding-left:22px}
80
+ .md li{margin:3px 0;line-height:1.65}
81
+ .md pre{background:#0b0f14;border:1px solid var(--line);border-radius:8px;padding:12px 14px;overflow:auto;margin:0 0 12px}
82
+ .md code{font-family:var(--mono);font-size:12px;color:#7ee787}
83
+ .md pre code{color:#c9d1d9}
84
+ .md table{border-collapse:collapse;margin:0 0 12px;display:block;overflow-x:auto}
85
+ .md th,.md td{border:1px solid var(--line);padding:6px 10px;font-size:12px;text-align:left}
86
+ .md th{background:#141b23;color:#fff}
87
+ .md a{color:#58a6ff}
88
+ /* 좁은 화면에서 190px 고정 내비가 자리를 다 먹어 본문이 한 글자씩 세로로 흘렀다(실제 브라우저에서 확인).
89
+ 테스트로는 안 보이는 종류라 눈으로 봐야 잡힌다. 좁아지면 내비를 가로 칩으로 접는다. */
90
+ @media (max-width:760px){
91
+ .dwrap{flex-direction:column}
92
+ .dnav{position:static;flex:0 0 auto;display:flex;flex-wrap:wrap;gap:4px;width:100%}
93
+ .dnav div{border:1px solid var(--line)}
94
+ .dbody{width:100%;padding:14px 16px}
95
+ }
65
96
  </style></head><body>
66
97
  <div id="bar">
67
98
  <div class="ttl"><span class="dot"></span><span id="proj">leerness</span><span style="color:var(--mut);font-weight:400">/ ontology</span></div>
68
- <div id="tabs"><span class="tab on" data-v="graph">그래프</span><span class="tab" data-v="roadmap">로드맵</span><span class="tab" data-v="tech">🛠 기술</span></div>
99
+ <div id="tabs"><span class="tab on" data-v="graph">그래프</span><span class="tab" data-v="roadmap">로드맵</span><span class="tab" data-v="tech">🛠 기술</span><span class="tab" data-v="docs">📄 문서</span></div>
69
100
  <span class="stat" id="stat"></span>
70
101
  <input id="search" placeholder="search nodes…" autocomplete="off">
71
102
  <div id="chips"></div>
@@ -76,6 +107,7 @@ canvas{position:fixed;inset:0;top:46px}
76
107
  <div id="hint">drag node · scroll zoom · drag bg pan · click node → details · search+Enter jump · f / dblclick fit · p export PNG · Esc close</div>
77
108
  <div id="vroadmap" class="view"></div>
78
109
  <div id="vtech" class="view"></div>
110
+ <div id="vdocs" class="view"></div>
79
111
  <script>
80
112
  var DATA = /*__DATA__*/null;
81
113
  var COLORS={task:'#58a6ff',plan:'#d29922',decision:'#39d0d8',lesson:'#e3b341',rule:'#bc8cff',skill:'#2dd4bf',feature:'#6e7681'};
@@ -172,7 +204,7 @@ window.addEventListener('keydown',function(ev){ if(ev.target&&ev.target.tagName=
172
204
  cv.addEventListener('dblclick',function(ev){ if(!hit(ev.offsetX,ev.offsetY)){_fit=true;fitView();} });
173
205
 
174
206
  // ── 탭 (1.36.53, UR-0062): 그래프 | 로드맵 | 🛠 기술 (토글 스위치 UI 제거 → CLI 가이드로 대체) ──
175
- var _views={roadmap:document.getElementById('vroadmap'),tech:document.getElementById('vtech')};
207
+ var _views={roadmap:document.getElementById('vroadmap'),tech:document.getElementById('vtech'),docs:document.getElementById('vdocs')};
176
208
  document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){
177
209
  document.querySelectorAll('.tab').forEach(function(x){x.classList.toggle('on',x===t);});
178
210
  var v=t.getAttribute('data-v');
@@ -234,10 +266,222 @@ document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){
234
266
  +'· 이 파일은 정적 HTML — 상태 변경은 터미널(또는 AI)에게 위 명령을 실행시키세요. AI 는 토글 상태를 자동 준수합니다.</div></div>';
235
267
  el.innerHTML=h;
236
268
  })();
269
+
270
+ // ── 📄 문서 탭 (1.36.96, P-0010) — .harness 핵심 문서 7종을 이 파일 안에서 그대로 읽는다.
271
+ // 렌더러는 **최소 부분집합**만 해석한다: 제목 · 목록 · 코드펜스 · 표 · 링크 · 강조.
272
+ // 그 밖의 모든 것(원시 HTML 포함)은 이스케이프된 평문으로 남는다 — 파서를 넓힐수록 XSS 면이 넓어진다.
273
+ // 이 템플릿은 String.raw 라 리터럴 백틱을 쓸 수 없다(템플릿이 끊긴다) → 정규식/문자열에서 \x60 을 쓴다.
274
+ function mdEsc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
275
+ // URL 허용: http(s) · mailto · 페이지앵커 · 상대경로. 그 외 **스킴이 붙은 것은 전부 거부**(javascript:, data:, vbscript: …).
276
+ // 브라우저 URL 파서는 탭/개행/CR 을 **제거한 뒤** 스킴을 읽는다 — 실측으로 확인했다(java + 탭 + script:
277
+ // 를 href 에 넣으면 protocol 이 javascript: 로 나온다). 지금은 링크 토크나이저가 공백류를 배제해 그런
278
+ // 입력이 애초에 링크가 되지 않지만 그건 **우연한 안전**이다. 토크나이저를 나중에 넓히면 되살아난다.
279
+ // (이 주석에 백틱을 쓰면 String.raw 템플릿이 거기서 끊긴다 — 바로 위에 적어 놓고 한 번 어겼다.)
280
+ // 판정 전에 브라우저와 같은 문자를 제거해, 우리가 보는 것과 브라우저가 보는 것을 일치시킨다.
281
+ // 탭·개행뿐 아니라 **C0 제어문자와 DEL 전부**를 제거한다 — 검수 실측에서 \x03·\x01·\x00·\x1F·\x7F 를
282
+ // 앞이나 중간에 끼우면 스킴 판정을 빠져나갔다. 브라우저는 그런 문자를 URL 에서 무시하거나 제거한다.
283
+ function mdUrl(u){var t=String(u||'').replace(/[\x00-\x1F\x7F]/g,'').trim();
284
+ // 브라우저는 역슬래시를 슬래시처럼 읽는다 — 역슬래시 2개로 시작하는 값은 프로토콜 상대 URL 로 해석된다
285
+ // (검수 실측: file://host/share, HTTPS 페이지에서는 https://host/share). 종전엔 슬래시 2개만 막았다.
286
+ if(/^[\\/]{2}/.test(t))return null;
287
+ if(/^(https?:\/\/|mailto:|#)/i.test(t))return t;
288
+ if(/^[a-z][a-z0-9+.\-]*:/i.test(t))return null;
289
+ return t;}
290
+ function mdInline(s){
291
+ // 코드스팬을 **먼저** 뽑아 자리표시자로 치환한다 — 안쪽의 * 나 [](…) 가 강조/링크로 해석되면 안 된다.
292
+ // 자리표시자는 U+0001 로 감싼다(이스케이프된 본문에 절대 나타나지 않는 문자). 감싸는 문자를 빠뜨리면
293
+ // 복원 정규식이 본문의 **모든 숫자**를 삼킨다 — 초안에서 실제로 그랬다.
294
+ // 문서에 U+0001 이 실제로 들어 있으면 자리표시자와 충돌한다 — 먼저 지운다(표시용 텍스트라 무해).
295
+ var out=mdEsc(s).replace(/[\x01\x02]/g,''),codes=[],links=[];
296
+ out=out.replace(/\x60([^\x60]+)\x60/g,function(m,c){codes.push(c);return '\x01'+(codes.length-1)+'\x01';});
297
+ // 수량자를 **유계**로 둔다. 종전 [^\]]* 는 닫히지 않은 대괄호마다 남은 문자열 전체를 다시 훑어 O(n²) 였다
298
+ // (실측: 64KB 입력 1,825ms · 최악 2,730ms — 그만큼 브라우저 메인 스레드가 멈춘다).
299
+ // 상한을 넘는 링크는 링크가 되지 않고 글자로 남는다(계약상 안전한 쪽으로 실패한다).
300
+ // URL 상한은 **이스케이프 후** 길이에 걸린다 — 원문 796자짜리 정상 쿼리 URL 이 & → &amp; 팽창으로
301
+ // 1,192자가 되어 거부됐다(검수 실측). 실측 비교로 라벨 300 / URL 4000 을 골랐다:
302
+ // 최악 115ms(무계는 1,747ms)이고 그 URL 은 링크된다.
303
+ out=out.replace(/\[([^\]\n]{0,300})\]\(([^)\s]{1,4000})\)/g,function(m,t,u){var v=mdUrl(u);
304
+ if(v===null)return m;
305
+ // 완성된 <a> 도 자리표시자로 빼 둔다 — 뒤따르는 강조 정규식이 href 안까지 다시 해석해
306
+ // href="https://a/<b>b</b>/c" 처럼 URL 을 훼손했다(검수 실측).
307
+ links.push('<a href="'+v+'" target="_blank" rel="noopener noreferrer">'+t+'</a>');
308
+ return '\x02'+(links.length-1)+'\x02';});
309
+ out=out.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>');
310
+ out=out.replace(/(^|[^*])\*([^*\n]+)\*/g,'$1<i>$2</i>');
311
+ out=out.replace(/\x02(\d+)\x02/g,function(m,i){return links[i];});
312
+ out=out.replace(/\x01(\d+)\x01/g,function(m,i){return '<code>'+codes[i]+'</code>';});
313
+ return out;}
314
+ function mdRender(src){
315
+ var ls=String(src||'').split(/\r?\n/),h='',i=0,list=null,olNext=0;
316
+ function endList(){if(list){h+='</'+list+'>';list=null;}}
317
+ function cells(r){return r.replace(/^\s*\|/,'').replace(/\|\s*$/,'').split('|').map(function(c){return c.trim();});}
318
+ while(i<ls.length){
319
+ var l=ls[i];
320
+ if(/^\s*\x60{3,}/.test(l)){ // 코드펜스 — 안쪽은 어떤 규칙도 적용하지 않는다
321
+ endList();var buf=[];i++;
322
+ while(i<ls.length&&!/^\s*\x60{3,}\s*$/.test(ls[i])){buf.push(ls[i]);i++;}
323
+ i++;h+='<pre><code>'+mdEsc(buf.join('\n'))+'</code></pre>';continue;}
324
+ var hd=l.match(/^(#{1,6})\s+(.*)$/);
325
+ if(hd){endList();var n=hd[1].length;h+='<h'+n+'>'+mdInline(hd[2])+'</h'+n+'>';i++;continue;}
326
+ if(l.indexOf('|')>=0&&i+1<ls.length&&ls[i+1].indexOf('|')>=0&&/^[\s:|\-]*-[\s:|\-]*$/.test(ls[i+1])){
327
+ endList();
328
+ // 구분행의 콜론을 문법으로는 받으면서 정렬을 버리면 사용자가 지정한 의미가 조용히 사라진다(검수 실측).
329
+ // 받은 이상 반영한다 — 왼쪽/가운데/오른쪽. (이 템플릿 안 주석에는 백틱을 쓸 수 없다.)
330
+ var al=cells(ls[i+1]).map(function(c){
331
+ var lft=c.charAt(0)===':',rgt=c.charAt(c.length-1)===':';
332
+ return lft&&rgt?' style="text-align:center"':(rgt?' style="text-align:right"':(lft?' style="text-align:left"':''));});
333
+ var A=function(k){return al[k]||'';};
334
+ h+='<table><thead><tr>'+cells(l).map(function(c,k){return '<th'+A(k)+'>'+mdInline(c)+'</th>';}).join('')+'</tr></thead><tbody>';
335
+ i+=2;
336
+ while(i<ls.length&&ls[i].indexOf('|')>=0&&ls[i].trim()!==''){
337
+ h+='<tr>'+cells(ls[i]).map(function(c,k){return '<td'+A(k)+'>'+mdInline(c)+'</td>';}).join('')+'</tr>';i++;}
338
+ h+='</tbody></table>';continue;}
339
+ var ul=l.match(/^\s*[-*+]\s+(.*)$/),ol=l.match(/^\s*(\d+)[.)]\s+(.*)$/);
340
+ if(ul||ol){var want=ul?'ul':'ol';
341
+ // 원문 번호를 지키지 않으면 "2. two" 가 브라우저에서 "1. two" 로, 1·3 이 1·2 로 표시된다
342
+ // — 문서의 의미가 조용히 바뀐다(검수 실측). 시작값은 ol start, 건너뛴 번호는 li value 로 보존한다.
343
+ if(list!==want){endList();h+=want==='ol'?('<ol start="'+ol[1]+'">'):'<ul>';list=want;olNext=ol?parseInt(ol[1],10):0;}
344
+ if(ul){h+='<li>'+mdInline(ul[1])+'</li>';}
345
+ else{var num=parseInt(ol[1],10);h+=(num===olNext?'<li>':'<li value="'+num+'">')+mdInline(ol[2])+'</li>';olNext=num+1;}
346
+ i++;continue;}
347
+ if(!l.trim()){endList();i++;continue;}
348
+ endList();h+='<p>'+mdInline(l)+'</p>';i++;}
349
+ endList();return h;}
350
+
351
+ (function(){
352
+ var dd=(DATA&&DATA.docs)||{files:[]},el=_views.docs,fl=dd.files||[];
353
+ if(!fl.length){el.innerHTML='<div class="note">문서 없음 — leerness handoff . 로 .harness 를 채우고 재생성하세요.</div>';return;}
354
+ var kb=function(n){return Math.round((n||0)/1024)+'KB';};
355
+ el.innerHTML='<div class="note">스냅샷 '+esc(String(dd.at||'').slice(0,19).replace('T',' '))+' UTC · 이 파일은 <b>정적 사본</b>입니다 — 최신 내용은 <b>leerness graph --html</b> 로 재생성하세요. 시크릿 값은 가려집니다(문서당 '+kb(dd.perCap)+' · 전체 '+kb(dd.totalCap)+' 상한).</div>'
356
+ +'<div class="dwrap"><div class="dnav">'
357
+ +fl.map(function(f,i){return '<div data-i="'+i+'"'+(i===0?' class="on"':'')+'>'+esc(f.title)+(f.missing?' <span style="color:var(--mut)">(없음)</span>':(f.guarded?' <span style="color:var(--mut)">🔒</span>':(f.truncated?' <span style="color:var(--mut)">✂</span>':'')))+'</div>';}).join('')
358
+ +'</div><div class="dbody md" id="dbody"></div></div>';
359
+ var body=document.getElementById('dbody');
360
+ function show(i){
361
+ var f=fl[i]||{},h='<div class="sec" style="margin-top:0">'+esc(f.title||'')+' <span style="color:var(--mut);font-weight:400;font-size:11px">'+esc(f.rel||'')+'</span></div>';
362
+ if(f.missing){h+='<div class="note">아직 없는 파일입니다 — <b>leerness handoff .</b> 실행 시 생성됩니다.</div>';}
363
+ // 자격증명 표지가 있는 문서는 **본문을 싣지 않는다** — 부분 마스킹의 빈틈으로 새는 것을 구조적으로 막는다.
364
+ else if(f.guarded){h+='<div class="note">🔒 이 문서에는 <b>자격증명으로 보이는 내용</b>이 있어 본문을 싣지 않았습니다('+(f.charsTotal||0)+'자). 파일을 직접 여세요: '+esc(f.rel||'')+'<br>이 파일은 공유될 수 있으므로, 부분적으로 가리는 대신 통째로 제외합니다.</div>';}
365
+ else if(f.unreadable){h+='<div class="note">읽지 못했습니다(권한/인코딩). 원본 파일은 그대로입니다 — 직접 여세요.</div>';}
366
+ else if(f.skipped){h+='<div class="note">⚠ 전체 상한('+kb(dd.totalCap)+')에 도달해 <b>담기지 않았습니다</b> — 원본 '+(f.charsTotal||0)+'자. '+esc(f.rel||'')+' 를 직접 여세요.</div>';}
367
+ else{
368
+ // "자" 는 코드포인트다 — String.length 를 쓰면 이모지 문서에서 두 배로 표시된다(검수 실측).
369
+ if(f.truncated)h+='<div class="note">⚠ 상한으로 잘렸습니다 — '+(f.chars||0)+'/'+(f.charsTotal||0)+'자('+(f.tail?'뒷부분':'앞부분')+'만). 전체는 '+esc(f.rel||'')+' 를 여세요.</div>';
370
+ h+=(f.lang==='json')?('<pre><code>'+mdEsc(f.text||'')+'</code></pre>'):mdRender(f.text||'');}
371
+ body.innerHTML=h;}
372
+ el.querySelectorAll('.dnav div').forEach(function(n){n.onclick=function(){
373
+ el.querySelectorAll('.dnav div').forEach(function(x){x.classList.toggle('on',x===n);});
374
+ show(parseInt(n.getAttribute('data-i'),10));};});
375
+ show(0);
376
+ })();
237
377
  </script></body></html>`;
238
378
 
239
379
  const _txt = v => (v == null ? '' : String(v));
240
- function _trunc(s, n) { s = _txt(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
380
+ // 1.36.96 (검수 P1): 문서 탭만 가드했는데 **같은 내용이 nodes·roadmap 으로도 임베드**된다
381
+ // `decision add "Authorization: Bearer <토큰>"` 이 DATA.nodes[].label / detail 로 원문 그대로 실렸다(실측).
382
+ // 그래프 쪽에는 마스킹조차 없었다. 임베드되는 **모든 사용자 텍스트**를 같은 술어로 통과시킨다.
383
+ // 자격증명 표지가 있으면 그 항목 텍스트를 통째로 대체하고, 아니면 2차 마스킹만 적용한다.
384
+ const _GUARD_TXT = '🔒 자격증명 표지 — 생략됨';
385
+ const _safe = (v) => { const t = _txt(v); return t && _hasCred(t) ? _GUARD_TXT : _redact(t); };
386
+ function _trunc(s, n) { s = _safe(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
387
+
388
+ // ── 📄 문서 탭 데이터 (1.36.96, P-0010) ──────────────────────────────────────────
389
+ // 사용자 요청은 "exe 실행기로 .harness 마크다운을 띄우기"였다. 실행기는 런타임 의존성 0 · install script 0
390
+ // 경계를 깨므로 만들지 않는다(1.36.90 에서 같은 경계를 테스트로 못박았다). 같은 목적을 정적 단일 파일로 이룬다.
391
+ // `tail: true` 는 **누적 로그**다 — 앞을 남기면 가장 오래된 것만 보인다(1.36.95 handoff 에서 겪은 그 방향 오류).
392
+ const DOC_FILES = [
393
+ { key: 'plan', title: '플랜 · 로드맵', rel: '.harness/plan.md' },
394
+ { key: 'skills', title: '스킬', rel: '.harness/skill-index.md' },
395
+ { key: 'tech', title: '기술 프로필', rel: '.harness/tech-profile.json', lang: 'json' },
396
+ { key: 'handoff', title: '세션 핸드오프', rel: '.harness/session-handoff.md' },
397
+ { key: 'state', title: '현재 상태', rel: '.harness/current-state.md' },
398
+ { key: 'progress', title: '진행 추적', rel: '.harness/progress-tracker.md', tail: true },
399
+ { key: 'decisions', title: '결정 기록', rel: '.harness/decisions.md', tail: true },
400
+ ];
401
+ const DOC_PER_CAP = 64 * 1024;
402
+ const DOC_TOTAL_CAP = 512 * 1024;
403
+ // ⚠ 오늘의 목록(7종 × 64KB = 448KB)으로는 전체 상한 512KB 에 **도달할 수 없다** — 목록이 8종 이상으로
404
+ // 늘 때를 위한 backstop 이다. 그래서 caps 를 주입 가능하게 뒀다: 도달 불가능한 분기는 "있다"고만 적으면
405
+ // 공허한 가드가 된다(그 함정을 이 저장소에서 반복해서 밟았다). 테스트가 작은 상한으로 실제로 발화시킨다.
406
+
407
+ // 줄 경계에서 자르고, 한 줄이 상한보다 길면 **코드포인트** 경계에서 자른다(서로게이트 쌍 분할 금지).
408
+ // 1.36.96 (검수 P2): 상한 단위를 **UTF-8 바이트**로 바꿨다. 종전엔 UTF-16 코드 단위를 세면서 "64KB"라고
409
+ // 적었고, 한글 문서에서는 문서당 실제 192KB · 합계 1.38MB 까지 실려 주장이 사실과 달랐다(실측: 1,152KB).
410
+ const _blen = (x) => Buffer.byteLength(x, 'utf8');
411
+ function _capDoc(s, cap, tail) {
412
+ s = _txt(s);
413
+ if (_blen(s) <= cap) return { text: s, truncated: false };
414
+ const ls = s.split('\n');
415
+ let acc = 0, keep = 0;
416
+ if (tail) {
417
+ for (let i = ls.length - 1; i >= 0; i--) { const add = (keep ? 1 : 0) + _blen(ls[i]); if (acc + add > cap) break; acc += add; keep++; }
418
+ } else {
419
+ for (let i = 0; i < ls.length; i++) { const add = (keep ? 1 : 0) + _blen(ls[i]); if (acc + add > cap) break; acc += add; keep++; }
420
+ }
421
+ // **"무엇을 내용으로 셀지"를 판정하지 않는다.** 이 자리에서 데이터 소실이 세 번 재발했다:
422
+ // ① `keep > 0` → 경계에 빈 줄 하나면 70,000자가 0자 ② `acc > 0` → 빈 줄 두 개면 1자
423
+ // ③ 내용 바이트(content) → CRLF 의 `\r` 과 공백/탭이 내용으로 세어져 다시 1~3자
424
+ // 매번 셈을 조금씩 틀렸으므로 셈을 없앤다. 온전한 줄로 채운 뒤, **남은 예산만큼 경계 줄을 코드포인트로
425
+ // 잘라 이어 붙인다**. 어떤 공백 조합에도 예산이 채워지고, 순서가 유지되므로 "앞부분/뒷부분" 고지가 참이 된다.
426
+ // (한때 둘 중 긴 쪽만 골랐는데, 경계 줄이 길면 **문서의 제목·푸터가 통째로 사라졌다** — 검수 실측.)
427
+ let packed = tail ? ls.slice(ls.length - keep).join('\n') : ls.slice(0, keep).join('\n');
428
+ // `\n` 으로만 나누므로 CRLF 문서에서는 각 줄 끝에 `\r` 이 남는다. head 방향에서 다음 줄이 안 들어가면
429
+ // 재조립 때 개행이 붙지 않아 **고립된 CR** 이 꼬리에 남는다(검수 실측: "A\r"). 표시 데이터에 제어문자를
430
+ // 남기지 않도록 잘라낸다 — tail 방향은 앞에 개행이 붙으므로 해당 없음.
431
+ if (!tail) packed = packed.replace(/\r$/, '');
432
+ const line = (tail ? ls[ls.length - 1 - keep] : ls[keep]) || '';
433
+ // 경계 줄을 **항상** 잘라 붙이면 평범한 문서까지 줄 중간에서 잘린다("줄 경계 절단" 속성이 깨진다).
434
+ // 혼자서도 상한을 넘는 줄, 즉 **어떤 예산으로도 통째로는 담길 수 없는 줄**일 때만 잘라 붙인다.
435
+ const mustCut = _blen(line) > cap;
436
+ const room = mustCut ? cap - acc - (keep && line ? 1 : 0) : 0; // 이어 붙일 개행 몫까지 뺀다
437
+ const cps = Array.from(line);
438
+ let n = 0, len = 0;
439
+ if (room > 0) {
440
+ if (tail) { for (let i = cps.length - 1; i >= 0 && len + _blen(cps[i]) <= room; i--) { len += _blen(cps[i]); n++; } }
441
+ else { for (let i = 0; i < cps.length && len + _blen(cps[i]) <= room; i++) { len += _blen(cps[i]); n++; } }
442
+ }
443
+ const part = n ? (tail ? cps.slice(cps.length - n).join('') : cps.slice(0, n).join('')) : '';
444
+ const text = !part ? packed : (!keep ? part : (tail ? part + '\n' + packed : packed + '\n' + part));
445
+ return { text, truncated: true };
446
+ }
447
+
448
+ function buildDocsData(root, nowIso, caps) {
449
+ const perCap = (caps && caps.perCap) || DOC_PER_CAP;
450
+ const totalCap = (caps && caps.totalCap) || DOC_TOTAL_CAP;
451
+ const minSlice = Math.max(256, Math.min(2048, Math.floor(totalCap / 8)));
452
+ const files = [];
453
+ let spent = 0;
454
+ for (const d of DOC_FILES) {
455
+ const base = { key: d.key, title: d.title, rel: d.rel, lang: d.lang || 'md', tail: !!d.tail };
456
+ const p = path.join(root, d.rel);
457
+ if (!exists(p)) { files.push(Object.assign({}, base, { missing: true, text: '', totalChars: 0, truncated: false })); continue; }
458
+ let raw;
459
+ try { raw = read(p); } catch { files.push(Object.assign({}, base, { unreadable: true, text: '', totalChars: 0, truncated: false })); continue; }
460
+ // **자격증명 표지가 있으면 본문을 싣지 않는다.** 값 단위 마스킹은 다섯 라운드 동안 계속 새어서
461
+ // 안전의 전제에서 뺐다(마스킹은 아래에서 두 번째 방어층으로만 남는다). 실측 비용 0.6%.
462
+ if (_hasCred(raw)) {
463
+ files.push(Object.assign({}, base, {
464
+ guarded: true, text: '', truncated: false,
465
+ totalChars: raw.length, charsTotal: Array.from(raw).length, totalBytes: _blen(raw), bytes: 0, chars: 0,
466
+ }));
467
+ continue;
468
+ }
469
+ const red = _redact(raw); // dashboard 와 같은 술어(두 번째 방어층)
470
+ const remain = totalCap - spent;
471
+ // 잔여 예산이 의미 없는 크기면 **조각을 싣지 않고 건너뛴다**. 종전엔 remain<=0 일 때만 건너뛰어
472
+ // 159자짜리 쓸모없는 조각이 "잘림"으로 표시됐다(측정으로 확인). 조각보다 "안 담았다"가 정확하다.
473
+ if (remain < minSlice) { files.push(Object.assign({}, base, { skipped: true, text: '', totalChars: red.length, totalBytes: _blen(red), bytes: 0, truncated: true, chars: 0, charsTotal: Array.from(red).length })); continue; }
474
+ const c = _capDoc(red, Math.min(perCap, remain), !!d.tail);
475
+ spent += _blen(c.text); // 예산도 바이트로 센다(상한과 같은 단위)
476
+ // 화면 고지의 "자" 는 **코드포인트**여야 한다. String.length(UTF-16 코드 단위)를 쓰면 이모지 문서에서
477
+ // 32,767/60,001자 로 나오지만 실제로는 16,384/30,001자다(검수 실측). 세어서 함께 싣는다.
478
+ files.push(Object.assign({}, base, {
479
+ text: c.text, totalChars: red.length, totalBytes: _blen(red), bytes: _blen(c.text), truncated: c.truncated,
480
+ chars: Array.from(c.text).length, charsTotal: Array.from(red).length,
481
+ }));
482
+ }
483
+ return { at: nowIso, perCap, totalCap, spent, files };
484
+ }
241
485
 
242
486
  // 하네스 표면 → {project, version, counts, nodes, edges}. deps 로 in-process 로더 주입(셸링 X).
243
487
  function buildGraphData(root, deps = {}) {
@@ -315,7 +559,45 @@ function buildGraphData(root, deps = {}) {
315
559
  // 1.36.53 (UR-0062): 기술 프로필 임베드 — 🛠 기술 탭 데이터
316
560
  const tech = (typeof deps._loadTechProfile === 'function' ? (deps._loadTechProfile(root) || null) : null) || { current: { languages: [], services: [] }, history: [] };
317
561
  const toggleRegistry = deps._toggleRegistry || {};
318
- return { project: rd.project || path.basename(root), version: rd.version || '', root, counts, nodes, edges, roadmap, toggles, toggleRegistry, tech };
562
+ // 필드마다 손으로 감싸면 반드시 빠뜨린다(문서 탭만 가드했다가 nodes·roadmap 으로 샜다).
563
+ // **임베드 직전 한 곳**에서 모든 문자열을 통과시킨다 — 지금 있는 표면도, 나중에 추가될 표면도 함께 덮인다.
564
+ // id·type 같은 자체 생성 값은 표지에 걸리지 않으므로 무해하다.
565
+ // **깊이 제한을 두면 그 너머가 통째로 우회한다.** 순환을 막으려 12단계 상한을 뒀더니, 12단계보다 깊은
566
+ // 객체는 그대로 반환돼 그 아래 문자열에 정규화가 아예 걸리지 않았다(검수 실측: tech-profile.json 은
567
+ // 임의 JSON 이라 실 CLI 로 도달 가능). 상한 대신 **방문 집합**으로 순환만 끊는다 — 깊이는 제한하지 않는다.
568
+ // 방문 집합을 **재귀 스택처럼** 써야 한다. 전역 집합으로 두면 같은 객체를 두 필드가 공유할 때
569
+ // 두 번째가 순환이 아닌데도 잘려 나간다(검수 실측: `{first:X, second:X}` 의 second 가 null 이 됐다).
570
+ // 들어갈 때 넣고 **나올 때 뺀다** — 진짜 순환(조상에 자신이 있는 경우)만 끊긴다.
571
+ // 재귀 깊이도 제한하지 않되, 스택이 터지면 그 가지만 표지 문자열로 대체한다(생성 자체가 실패하면 안 된다).
572
+ // 깊이 상한 자체가 문제가 아니라 **상한을 넘겼을 때 원본을 그대로 통과시킨 것**이 문제였다(그게 우회였다).
573
+ // 상한을 넘으면 **자리표시자 문자열로 대체**한다 — 아무것도 새지 않고, 뒤이은 JSON.stringify 도 안전하다
574
+ // (직렬화 자체가 약 4천 단계에서 스택을 터뜨린다 — 24KB 짜리 유효 JSON 하나로 생성이 실패했다).
575
+ const MAXD = 200; // 정상 데이터는 이 근처에도 오지 않는다
576
+ const _deepSafe = (v, path, depth) => {
577
+ if (typeof v === 'string') return _safe(v);
578
+ if (!v || typeof v !== 'object') return v;
579
+ const dep = depth || 0;
580
+ if (dep > MAXD) return _GUARD_TXT; // fail-safe: 원본이 아니라 표시 문자열로 대체
581
+ const p = path || new Set();
582
+ if (p.has(v)) return null; // 조상에 자신이 있다 = 순환 → 잘라낸다
583
+ p.add(v);
584
+ try {
585
+ if (Array.isArray(v)) return v.map(x => _deepSafe(x, p, dep + 1));
586
+ const o = {};
587
+ for (const k of Object.keys(v)) o[k] = _deepSafe(v[k], p, dep + 1);
588
+ return o;
589
+ } finally {
590
+ p.delete(v); // 형제 노드가 공유 참조를 다시 쓸 수 있게 되돌린다
591
+ }
592
+ };
593
+ // 1.36.96 (P-0010): 📄 문서 탭 — .harness 핵심 문서 7종 스냅샷(마스킹 + 상한 + 잘림 고지).
594
+ const docs = buildDocsData(root, deps.nowIso || new Date().toISOString());
595
+ // `docs` 는 buildDocsData 에서 **이미 같은 술어를 통과했고 회계(spent/bytes)도 거기서 확정**됐다.
596
+ // 여기서 다시 변환하면 텍스트가 미세하게 바뀌어 "보고한 spent = 실제 임베드 바이트" 불변식이 깨진다
597
+ // (검수 실측: 1바이트 차이). 두 번 처리하지 않는다.
598
+ const safe = _deepSafe({ project: rd.project || path.basename(root), version: rd.version || '', root, counts, nodes, edges, roadmap, toggles, toggleRegistry, tech });
599
+ safe.docs = docs;
600
+ return safe;
319
601
  }
320
602
 
321
603
  // `leerness graph --html [path] [--out file] [--json]` 핸들러.
@@ -338,4 +620,4 @@ function graphHtmlCmd(root, deps = {}, outFile) {
338
620
  return summary;
339
621
  }
340
622
 
341
- module.exports = { graphHtmlCmd, buildGraphData };
623
+ module.exports = { graphHtmlCmd, buildGraphData, buildDocsData, _capDoc, DOC_FILES, DOC_PER_CAP, DOC_TOTAL_CAP };
package/lib/pure-utils.js CHANGED
@@ -24,11 +24,121 @@ const _SECRET_ASSIGN = /\b([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|
24
24
  // (헤더 밖의 맨 `Bearer <값>` 은 아래 SHAPE 규칙이 형태로 잡는다 — 산문 훼손 위험을 감수하지 않는다.)
25
25
  const _SECRET_AUTH_HEADER = /\b((?:proxy-)?authorization)([ \t]*[:=][ \t]*)(?:(Bearer|Basic|Token|ApiKey|Digest)([ \t]+))?([A-Za-z0-9._~+/-]+=*)/gi;
26
26
  const _SECRET_SHAPE = /\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|ghs_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|ASIA[0-9A-Z]{12,}|AIza[A-Za-z0-9_-]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
27
+ // 1.36.96 (검수 P1): 마스킹이 **스캐너와 다른(더 약한) 목록**을 쓰고 있었다. 실측으로 6종이 그대로 새어
28
+ // 화면에 실렸다 — DB 접속문자열 비밀번호 · GitLab PAT · npm 토큰 · Stripe live 키 · SendGrid · 개인키 블록.
29
+ // 전부 `SECRET_PATTERNS`(catalogs.js)에 **이미 있던** 패턴이다. 한쪽만 강화된 전형적인 표면 불일치라
30
+ // 같은 목록을 공유한다. placeholder/entropy 가드도 스캐너와 동일하게 적용해 오탐을 늘리지 않는다.
31
+ // (catalogs.js 는 require 가 없어 순환이 생기지 않는다. 그래도 지연 로드 + 실패 시 기존 규칙만 사용.)
32
+ // 개인키는 **블록 전체**를 지운다. 스캐너는 BEGIN 줄만 탐지하면 충분하지만(있다/없다), 화면에 싣는
33
+ // 텍스트는 본문(base64)이 남으면 키가 그대로 노출된다 — 실측에서 BEGIN 줄만 *** 가 되고 본문은 남았다.
34
+ // `[\s\S]*?` 는 **BEGIN 과 무관한 END 사이의 산문까지** 삼킨다 — 종료되지 않은 키 하나가 문서 뒤쪽의
35
+ // END 줄과 짝지어져 그 사이 전부가 사라질 수 있다(변이 실험 중 발견). 사이에는 PEM 헤더·base64·빈 줄만 허용한다.
36
+ // **문턱과 문자 조성으로는 키 본문과 산문을 가를 수 없다.** 네 번 시도했고 매번 한쪽이 깨졌다:
37
+ // 16자 → 인용 문서의 식별자 삼킴 · 40자 → 짧게 감긴 키 누출 ·
38
+ // "숫자/기호 포함" → 영문자만인 실제 키 줄 누출(검수가 진짜 RSA 키로 51줄 누출을 재현) ·
39
+ // 그 역 → `PolicyVersion2026Final` 같은 정상 식별자 삼킴.
40
+ // 그래서 값이 아니라 **문맥과 실패 방향**으로 정한다. BEGIN 마커 뒤의 **공백 없는 한 줄**은 키 재료로 본다.
41
+ // 산문은 거의 항상 공백을 포함하므로 문단은 살아남고, 어떤 조성의 키 본문도 새지 않는다.
42
+ // 대가: BEGIN 을 인용한 문서에서 **바로 뒤의 단일 토큰 한 줄**이 가려진다. 원본 파일은 그대로이고
43
+ // 이건 표시용 스냅샷이므로, 누출보다 이 쪽이 낫다 — 표시용 마스킹은 안전한 쪽으로 실패해야 한다.
44
+ // PGP 블록의 `=CRC` 줄도 이 규칙에 자연히 포함된다.
45
+ const _PEM_HDR = '(?:Proc-Type|DEK-Info|Comment|Subject|Originator|Version|MessageID|Charset|X-[A-Za-z0-9-]+):[^\\r\\n]*';
46
+ // 줄 **끝까지** 공백이 없어야 한다 — 앵커가 없으면 산문 줄의 첫 낱말만 먹고 나머지를 남긴다(실측).
47
+ const _PEM_BODY = '(?!-----)\\S+(?=[ \\t]*(?:\\r?\\n|$))';
48
+ // 1.36.96: **값 단위 마스킹이 완벽해야 하는 구조를 버린다.**
49
+ // 문서 뷰어에서 다섯 라운드 동안 누출 경로가 계속 나왔다 — 인용문(`> `)으로 감싼 PEM, base64 의 `/`·`+`,
50
+ // 소문자 `bearer`, placeholder 모양 토큰, 줄 앞 접두어… 값의 형태로 가르려는 시도마다 반례가 있었다.
51
+ // 그래서 **자격증명 표지가 하나라도 있으면 그 문서의 본문을 아예 싣지 않는다.** 마스킹은 방어의 두 번째 층으로만 남고,
52
+ // "완벽한 마스킹"이 안전의 전제에서 빠진다.
53
+ // 비용 실측(실제 문서 156개): 표지가 걸린 파일 **1개(0.6%)** — 사실상 공짜다.
54
+ // 표지는 **값이 아니라 형식**으로만 정의한다(줄 앞 접두어·대소문자·본문 조성에 의존하지 않는다).
55
+ // 검수가 표지를 빠져나가는 네 형태를 실행으로 보였다 — RFC6750 의 짧은(19자) Bearer, 세션 쿠키,
56
+ // compact JWE(5분절이라 `eyJ…eyJ` 형태가 아니다), 마크다운 강조로 감싼 `` `Bearer` `` .
57
+ // 넓히기 전에 비용을 쟀다: 실제 문서 156개에서 **추가로 걸리는 파일 0개**. 그래서 셋 다 넓힌다.
58
+ const _CRED_MARKERS = [
59
+ /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/,
60
+ // JWT(3분절)와 JWE(5분절)를 함께 — 두 번째 분절이 비어 있을 수 있다(dir 알고리즘)
61
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{2,}/,
62
+ // 스킴과 값 사이에 마크다운 강조(`**` · `` ` ``)가 낄 수 있다. RFC6750 토큰은 짧을 수 있어 16자로 잡는다.
63
+ /\bbearer\b[^A-Za-z0-9\r\n]{0,4}[A-Za-z0-9_\-.=+/~]{16,}/i,
64
+ /\b(?:proxy-)?authorization[ \t]*[:=]/i,
65
+ /\b(?:set-)?cookie[ \t]*[:=]/i,
66
+ /\b(?:gh[pousr]_|github_pat_|sk-|sk_live_|rk_live_|xox[baprs]-|xapp-|AKIA|ASIA|AIza|ya29\.|glpat-|npm_|SG\.)/,
67
+ /\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@/i,
68
+ ];
69
+ function hasCredentialMarker(s) {
70
+ const t = String(s == null ? '' : s);
71
+ return _CRED_MARKERS.some(re => re.test(t));
72
+ }
73
+
74
+ // 카탈로그의 Bearer 문자군은 `[A-Za-z0-9_\-.=]` 라 base64 의 `/` `+` 에서 끊긴다 — 앞 조각이 20자 미만이면
75
+ // `Bearer <실토큰>` 이 통째로 남는다(실측: 앞 8자 뒤 `/` 인 40자 토큰이 전혀 가려지지 않았다).
76
+ // `Authorization:` 헤더 형태는 _SECRET_AUTH_HEADER 가 잡지만 맨 `Bearer …` 는 이 규칙이 필요하다.
77
+ // 스캐너 쪽 문자군도 같은 구멍이 있다(T-0085 로 남겼다) — 여기서는 표시 누출부터 막는다.
78
+ const _SECRET_BEARER = /\bBearer\s+[A-Za-z0-9_\-.=+/]{20,}/g;
79
+ const _SECRET_PEM = new RegExp('-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:[ \\t]*\\r?\\n[ \\t]*(?:' + _PEM_HDR + '|' + _PEM_BODY + ')?)*[ \\t]*\\r?\\n[ \\t]*-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----', 'g');
80
+ // END 줄이 없으면 위 규칙이 매치되지 않아 BEGIN 줄만 지워지고 **본문 base64 가 그대로 남았다**(검수 실측).
81
+ // 붙여넣다 잘린 키가 흔하므로, BEGIN 뒤에 이어지는 base64/헤더 줄까지 함께 지운다.
82
+ // ① 암호화 키는 `Proc-Type:`/`DEK-Info:` 헤더 뒤 **빈 줄**이 오고 본문이 시작한다 — 빈 줄에서 멈추면 본문이 샌다.
83
+ // ② 반대로 아무 `Name: value` 줄이나 먹으면 BEGIN 을 인용한 정책 문서의 산문을 삼킨다(둘 다 검수 실측).
84
+ // 그래서 헤더는 **PEM 표준 이름만** 허용하고, 본문 base64 줄이 **최소 하나** 있을 때만 확장한다.
85
+ const _SECRET_PEM_OPEN = new RegExp('-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:[ \\t]*\\r?\\n[ \\t]*' + _PEM_HDR + ')*(?:[ \\t]*\\r?\\n[ \\t]*)*(?:[ \\t]*\\r?\\n[ \\t]*(?:' + _PEM_BODY + '))+[ \\t]*\\r?\\n?', 'g');
86
+
87
+ // **탐지용 문맥 휴리스틱은 가림에 그대로 쓰면 안 된다.** 탐지의 오탐은 경고 한 줄이지만, 가림의 오탐은
88
+ // 사용자가 보는 문서를 조용히 파괴한다(대시보드도 이 함수를 쓴다). 검수 실측으로 확인한 두 가지:
89
+ // 'AWS secret commit <40자 커밋해시> was reverted' → 문장 전체가 *** 로 사라졌다.
90
+ // 'Use Bearer authenticationMiddleware for API requests.' → 'Use *** for API requests.'
91
+ // 엔트로피 게이트(_looksSecretLike)로는 갈리지 않았다 — 진짜 AWS 키까지 placeholder 로 판정된다(실측).
92
+ // 그래서 문맥 패턴은 가림에서 제외하고, Bearer 는 값에 숫자가 있을 때만 가린다.
93
+ // 남는 구멍: `Authorization:` 없이 적힌 **영문자만으로 된** Bearer 토큰. 헤더 형태는 _SECRET_AUTH_HEADER 가,
94
+ // 실제 토큰 형식(JWT·ghp_·AKIA·sk- 등)은 형태 패턴이 잡는다.
95
+ // 초판은 AWS 문맥 패턴을 통째로 제외했는데, 그러면 `aws secret access key <40자>` 처럼 **구분자 없는 실값이
96
+ // 샌다**(검수 실측). 문장을 삼키지 않으면서 값만 지우도록, 이 패턴은 **꼬리 자격증명만** 치환한다.
97
+ // commit 해시가 같은 형태라 가려지는 것은 감수한다 — 문장 구조는 남으므로 파괴가 아니다.
98
+ const _REDACT_SKIP = new Set();
99
+ const _REDACT_TAIL = {
100
+ 'AWS Secret Access Key (context)': /(["']?)([A-Za-z0-9/+]{40})(["']?)$/,
101
+ };
102
+ // Bearer 값도 같은 결론이다. 길이 문턱(32자)은 20~31자 실토큰을 놓쳤고, 모양 판별(camelCase 예외)은
103
+ // `Bearer abcdefghijklmnopqrst` · `Bearer CorrectHorseBatteryStaple` 같은 **유효한 불투명 토큰을 산문으로
104
+ // 판정해 노출**했다(검수 실측). 불투명 토큰이 소문자나 낱말꼴이 아니라는 보장이 없다 — 예외를 없앤다.
105
+ // 대가: `Use Bearer authenticationMiddleware for API requests.` 같은 문장이 `Use *** for …` 가 된다.
106
+ // 카탈로그가 이미 20자 이상만 후보로 잡으므로 `Bearer 인증` 같은 짧은 표현은 영향이 없다.
107
+ const _REDACT_GUARD = {};
108
+ let _SECRET_CATALOG = null;
109
+ function _secretCatalog() {
110
+ if (_SECRET_CATALOG) return _SECRET_CATALOG;
111
+ try { _SECRET_CATALOG = require('./catalogs').SECRET_PATTERNS || []; } catch { _SECRET_CATALOG = []; }
112
+ return _SECRET_CATALOG;
113
+ }
27
114
  function redactSecrets(s, max) {
28
115
  let out = String(s == null ? '' : s)
29
116
  .replace(_SECRET_AUTH_HEADER, (m, key, sep, scheme, gap) => `${key}${sep}${scheme ? scheme + (gap || ' ') : ''}***`)
30
117
  .replace(_SECRET_ASSIGN, (m, k) => `${k}=***`)
31
- .replace(_SECRET_SHAPE, '***');
118
+ .replace(_SECRET_SHAPE, '***')
119
+ .replace(_SECRET_PEM, '***')
120
+ .replace(_SECRET_PEM_OPEN, '***\n')
121
+ .replace(_SECRET_BEARER, '***');
122
+ for (const p of _secretCatalog()) {
123
+ const { re, valueGroup, requireSecretLike } = p;
124
+ if (!re || _REDACT_SKIP.has(p.name)) continue;
125
+ const guard = _REDACT_GUARD[p.name];
126
+ try {
127
+ out = out.replace(re, (...args) => {
128
+ const m0 = args[0];
129
+ const val = (valueGroup != null) ? args[valueGroup] : m0;
130
+ if (!val) return m0;
131
+ if (guard && !guard(m0)) return m0; // 가림 전용 추가 가드
132
+ const tailRe = _REDACT_TAIL[p.name]; // 꼬리 값만 지우는 패턴
133
+ if (tailRe) { const t = m0.match(tailRe); return t ? m0.slice(0, m0.length - t[0].length) + t[1] + '***' + t[3] : m0; }
134
+ if (_isPlaceholderSecret(val)) return m0; // .env.example 의 더미값
135
+ if (valueGroup != null && requireSecretLike && !_looksSecretLike(val)) return m0;
136
+ if (valueGroup == null) return '***';
137
+ const i = m0.indexOf(val); // 값만 지우고 맥락은 남긴다
138
+ return i < 0 ? '***' : m0.slice(0, i) + '***' + m0.slice(i + val.length);
139
+ });
140
+ } catch { /* 한 패턴이 실패해도 나머지 마스킹은 계속한다 */ }
141
+ }
32
142
  if (max && out.length > max) out = out.slice(0, max) + '…';
33
143
  return out;
34
144
  }
@@ -1321,7 +1431,7 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1321
1431
  module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
1322
1432
  _parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
1323
1433
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
1324
- _isSecretKey, redactSecrets, compareVer, parseHarnessVersion,
1434
+ _isSecretKey, redactSecrets, hasCredentialMarker, compareVer, parseHarnessVersion,
1325
1435
  _isPlaceholderSecret, _looksSecretLike,
1326
1436
  _mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue,
1327
1437
  _parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.95",
3
+ "version": "1.36.96",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -8326,6 +8326,461 @@ total++;
8326
8326
  if (!ok) failed++;
8327
8327
  }
8328
8328
 
8329
+ // 1.36.96 (P-0010, 사용자 승인 범위): leerness.html 에 📄 문서 탭 — .harness 핵심 문서 7종을 그대로 읽는다.
8330
+ // 사용자 요청은 "exe 실행기"였지만 실행기는 **런타임 의존성 0 · install script 0** 경계를 깬다(1.36.90 에서
8331
+ // 같은 경계를 테스트로 못박았다). 같은 목적을 정적 단일 파일 안에서 이룬다.
8332
+ // 렌더러는 **최소 부분집합**만 해석한다(제목·목록·코드펜스·표·링크·강조). 나머지는 이스케이프된 평문 —
8333
+ // 파서를 넓힐수록 XSS 면이 넓어지고, 이 파일은 사용자 문서를 그대로 싣는다.
8334
+ // 이 블록이 잡아야 하는 것(전부 변이로 확인): 원시 HTML 통과 · javascript:/data: 링크 · 표/제목/링크텍스트
8335
+ // 미이스케이프 · 코드펜스 안 마크다운 해석 · 코드스팬 자리표시자가 본문 숫자를 삼킴 · 시크릿 미마스킹 ·
8336
+ // 누적 로그 방향 뒤집힘 · 줄경계/코드포인트 절단 붕괴 · 전체 상한에서 조용한 누락.
8337
+ total++;
8338
+ {
8339
+ let ok = false;
8340
+ const _d = [];
8341
+ const dbg = {};
8342
+ try {
8343
+ const vm = require('vm');
8344
+ const G = require(path.resolve(path.dirname(CLI), '..', 'lib', 'graph.js'));
8345
+ const w = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-docs96-')); _d.push(w);
8346
+ fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify({ name: 'd96', version: '0.1.0' }, null, 2));
8347
+ cp.spawnSync(process.execPath, [CLI, 'init', w, '--yes', '--no-stale-check'], { encoding: 'utf8', timeout: 180000 });
8348
+ const H = path.join(w, '.harness');
8349
+ const setupOk = fs.existsSync(H); // 셋업이 조용히 실패하면 아래 단언이 공허해진다
8350
+
8351
+ // ── T1: 렌더러 — 허용 부분집합만 해석하고, 그 밖은 전부 이스케이프된 평문.
8352
+ // 생성된 HTML 에서 함수를 **떼어내 실제로 실행**한다(소스 grep 이 아니라 동작).
8353
+ cp.spawnSync(process.execPath, [CLI, 'graph', w, '--html'], { encoding: 'utf8', timeout: 180000 });
8354
+ const html = fs.readFileSync(path.join(w, 'leerness.html'), 'utf8');
8355
+ const wired = html.includes('data-v="docs"') && /"docs":\{/.test(html) && html.includes('세션 핸드오프');
8356
+ const grab = (n) => { const i = html.indexOf('function ' + n + '('); if (i < 0) return ''; const j = html.indexOf('\nfunction ', i + 1); return html.slice(i, j < 0 ? html.indexOf('\n(function(){', i) : j); };
8357
+ const sb = { console };
8358
+ vm.createContext(sb);
8359
+ vm.runInContext(['mdEsc', 'mdUrl', 'mdInline', 'mdRender'].map(grab).join('\n') + '\nthis.R=mdRender;this.I=mdInline;this.U=mdUrl;', sb);
8360
+ const R2 = sb.R, I2 = sb.I, U2 = sb.U;
8361
+ // 검수 지적: 제목은 H2 만, 허용 링크는 HTTPS 하나만 검사해 나머지가 끊겨도 통과했다.
8362
+ // 경계(H1·H6)와 허용 스킴 전 종류를 각각 단언한다.
8363
+ const T1 = wired
8364
+ && R2('# 하나') === '<h1>하나</h1>' && R2('## 제목') === '<h2>제목</h2>'
8365
+ && R2('### 셋') === '<h3>셋</h3>' && R2('#### 넷') === '<h4>넷</h4>'
8366
+ && R2('##### 다섯') === '<h5>다섯</h5>' && R2('###### 여섯') === '<h6>여섯</h6>'
8367
+ && R2('####### 일곱') === '<p>####### 일곱</p>' // 7단계는 제목이 아니다(평문)
8368
+ && R2('- a\n- b') === '<ul><li>a</li><li>b</li></ul>'
8369
+ && R2('1. a') === '<ol start="1"><li>a</li></ol>'
8370
+ // 원문 번호를 지키지 않으면 "2. two" 가 브라우저에서 "1. two" 로 보인다 — 문서 의미가 조용히 바뀐다(검수 실측).
8371
+ && R2('2. two') === '<ol start="2"><li>two</li></ol>'
8372
+ && R2('1. one\n3. three') === '<ol start="1"><li>one</li><li value="3">three</li></ol>'
8373
+ // 구분행의 콜론을 문법으로 받으면서 정렬을 버리면 사용자가 지정한 의미가 사라진다(검수 실측).
8374
+ && (function () { const s = R2('| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |');
8375
+ return s.includes('text-align:left') && s.includes('text-align:center') && s.includes('text-align:right'); })()
8376
+ && R2('| a |\n|---|\n| 1 |') === '<table><thead><tr><th>a</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>'
8377
+ && I2('**굵게** 와 *기울임*') === '<b>굵게</b> 와 <i>기울임</i>'
8378
+ && I2('명령 `npm test` 실행') === '명령 <code>npm test</code> 실행'
8379
+ && R2('```js\nvar a=1;\n```') === '<pre><code>var a=1;</code></pre>'
8380
+ && R2('| a |\n|---|\n| 1 |') === '<table><thead><tr><th>a</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>'
8381
+ // 허용 스킴 4종을 각각 — HTTPS 하나만 보면 http/mailto/앵커/상대경로가 끊겨도 통과한다(검수 실측 변이).
8382
+ && I2('[d](https://example.com/x)') === '<a href="https://example.com/x" target="_blank" rel="noopener noreferrer">d</a>'
8383
+ && /href="http:\/\/e\.test\/a"/.test(I2('[d](http://e.test/a)'))
8384
+ && /href="mailto:a@b\.c"/.test(I2('[d](mailto:a@b.c)'))
8385
+ && /href="#앵커"/.test(I2('[d](#앵커)'))
8386
+ && /href="\.harness\/plan\.md"/.test(I2('[d](.harness/plan.md)'))
8387
+ // 상한은 **이스케이프 후** 길이에 걸린다 — 원문 796자 URL 이 &amp; 팽창으로 거부됐다(검수 실측).
8388
+ // 픽스처가 1,192자라 상한을 1,200 으로 낮추는 변이가 통과했다 — 4,000 에 가까운 길이로 판별한다.
8389
+ && I2('[d](https://e.test/q?' + Array.from({ length: 260 }, (_, i) => 'k' + i + '=v' + i).join('&') + ')').includes('<a ')
8390
+ // 코드스팬 자리표시자가 감싸는 문자를 잃으면 본문의 **모든 숫자**가 코드로 바뀐다(초안이 그랬다)
8391
+ && I2('버전 1.36.96 에서 89 도구') === '버전 1.36.96 에서 89 도구'
8392
+ && I2('`a` 뒤 42 그리고 `b`') === '<code>a</code> 뒤 42 그리고 <code>b</code>'
8393
+ // XSS · 스킴 거부
8394
+ && !/<script/i.test(R2('<script>alert(1)</script>')) && R2('<script>alert(1)</script>').includes('&lt;script&gt;')
8395
+ && !/<img/i.test(R2('<img src=x onerror=alert(1)>')) && R2('<img src=x onerror=alert(1)>').includes('&lt;img')
8396
+ // 부재만 단언하면 함수가 **빈 문자열을 돌려줘도** 통과한다 — 거부된 링크는 원문이 글자로 남아야 하므로
8397
+ // 완전 일치로 단언한다(4가지 위험 스킴 + 프로토콜 상대).
8398
+ && I2('[x](javascript:alert(1))') === '[x](javascript:alert(1))'
8399
+ && I2('[x](JaVaScRiPt:alert(1))') === '[x](JaVaScRiPt:alert(1))'
8400
+ && I2('[x](data:text/html,y)') === '[x](data:text/html,y)'
8401
+ && I2('[x](vbscript:m)') === '[x](vbscript:m)'
8402
+ && I2('[x](//evil.example)') === '[x](//evil.example)'
8403
+ // 브라우저는 역슬래시를 슬래시처럼 읽는다 — 슬래시 2개만 막으면 역슬래시 2개가 통과한다(검수 실측).
8404
+ && U2('\\\\evil.example/share') === null && U2('/\\evil.example/share') === null
8405
+ // 링크를 만든 뒤 강조 정규식이 href 안까지 다시 해석해 URL 을 훼손했다(검수 실측).
8406
+ && I2('[t](https://a/**b**/c)') === '<a href="https://a/**b**/c" target="_blank" rel="noopener noreferrer">t</a>'
8407
+ // 문서에 U+0001/U+0002 가 실제로 있으면 자리표시자와 충돌한다 — 입력에서 제거한다.
8408
+ && I2('a\x01b \u0060c\u0060') === 'ab <code>c</code>' && I2('x\x020y') === 'x0y'
8409
+ // 닫히지 않은 대괄호마다 남은 문자열을 다시 훑던 O(n²)(실측 64KB 1,825ms) — 유계 수량자로 선형.
8410
+ && (function () { const t0 = Date.now(); R2('['.repeat(65536)); return Date.now() - t0 < 400; })()
8411
+ // "나쁜 것이 없다"만 보면 링크 텍스트를 URL 로 바꿔치기하는 변이가 통과한다(실제로 살아남았다).
8412
+ // **옳은 것이 있다**(이스케이프된 원문 텍스트)까지 함께 단언한다.
8413
+ && !/<b>/.test(I2('[<b>t</b>](https://a.b)')) && I2('[<b>t</b>](https://a.b)').includes('&lt;b&gt;t&lt;/b&gt;')
8414
+ && !/onmouseover="alert/.test(I2('[x](https://a" onmouseover="alert(1))')) && I2('[x](https://a" onmouseover="alert(1))').includes('&quot;')
8415
+ && !/<b>|<i>/.test(R2('| <b>x</b> |\n|---|\n| <i>y</i> |')) && R2('| <b>x</b> |\n|---|\n| <i>y</i> |').includes('&lt;b&gt;x&lt;/b&gt;')
8416
+ && !/<img/i.test(R2('# <img src=x onerror=1>')) && R2('# <img src=x onerror=1>').includes('&lt;img')
8417
+ // 브라우저는 URL 에서 탭/개행/CR 을 제거한 뒤 스킴을 읽는다(실측: href="java\tscript:…" → protocol
8418
+ // javascript:). 링크 토크나이저가 공백류를 배제하는 것에 기대지 않고 **술어 자체**가 거부해야 한다.
8419
+ && U2('java\tscript:alert(1)') === null && U2('java\nscript:alert(1)') === null
8420
+ && U2('java\rscript:alert(1)') === null && U2('\tjavascript:alert(1)') === null
8421
+ // 탭·개행만 지우면 다른 C0 제어문자로 빠져나간다(검수 실측: \x03·\x01·\x00·\x1F·\x7F 전부 통과했다)
8422
+ && ['\x00', '\x01', '\x03', '\x1F', '\x7F'].every(c => U2(c + 'javascript:alert(1)') === null)
8423
+ && U2('java\x03script:alert(1)') === null && U2('java\x7Fscript:alert(1)') === null
8424
+ && U2('https://ok.example/a') === 'https://ok.example/a' // 정상 URL 은 그대로(과잉 거부 아님)
8425
+ // 엔티티·제로폭은 브라우저에서 상대 URL 로 떨어진다(실측: protocol=file:) — 링크가 생겨도 무해하다
8426
+ && !/href="javascript:/i.test(I2('[t](javascript&#58;alert(1))'))
8427
+ // 코드펜스 안은 이스케이프될 뿐 아니라 **마크다운도 해석되지 않아야** 한다
8428
+ // (mdInline 도 mdEsc 를 부르므로 XSS 단언만으로는 이 변이가 살아남는다)
8429
+ && (function () { const s = R2('```\n**굵게** 와 [링크](https://a.b)\n```'); return s.includes('**굵게**') && !/<b>/.test(s) && !/<a /.test(s); })()
8430
+ // 미종료 코드펜스의 내용을 버려도 잡히지 않았다(검수 실측) — 내용 보존까지 단언한다.
8431
+ && R2('```\nkept line one\nkept line two') === '<pre><code>kept line one\nkept line two</code></pre>'
8432
+ // 허용 밖은 평문 — 주석·CDATA 회귀가 정확한 단언들 사이를 빠져나갔다(검수 실측)
8433
+ && R2('<div class="x">y</div>').includes('&lt;div') && R2('---') === '<p>---</p>'
8434
+ && R2('<!--<img src=x onerror=1>-->') === '<p>&lt;!--&lt;img src=x onerror=1&gt;--&gt;</p>'
8435
+ && R2('<![CDATA[<script>x</script>]]>') === '<p>&lt;![CDATA[&lt;script&gt;x&lt;/script&gt;]]&gt;</p>'
8436
+ && R2('a & b < c > d "e"') === '<p>a &amp; b &lt; c &gt; d &quot;e&quot;</p>';
8437
+ dbg.render = String(T1) + (wired ? '' : ' (배선X)');
8438
+
8439
+ // ── T2: 데이터 — 마스킹 · 방향 · 줄경계 · 코드포인트 · 결측 고지.
8440
+ // 마스킹은 **스캐너와 같은 목록**(catalogs.SECRET_PATTERNS)을 써야 한다. 자체 축소판을 쓰던 종전엔
8441
+ // DB 접속문자열 비밀번호 · GitLab PAT · npm · Stripe · SendGrid · 개인키 본문이 그대로 화면에 실렸다(검수 실측 6종).
8442
+ // 픽스처는 길이를 손으로 세지 말고 생성한다 — 손으로 센 SendGrid 길이가 세 번 틀렸다.
8443
+ const _CH = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
8444
+ let _sd = 7;
8445
+ const _rnd = (n) => { let s = ''; for (let i = 0; i < n; i++) { _sd = (_sd * 1103515245 + 12345) % 2147483648; s += _CH[_sd % _CH.length]; } return s; };
8446
+ const _dbPw = _rnd(20); // 값을 직접 들고 있는다 — URL 을 다시 파싱하면 인덱스를 틀린다
8447
+ const SEC = {
8448
+ dbUrl: 'DATABASE_URL=postgres://alice:' + _dbPw + '@db.example/app',
8449
+ glpat: 'glpat-' + _rnd(24),
8450
+ npm: 'npm_' + _rnd(36),
8451
+ stripe: 'sk_live_' + _rnd(24),
8452
+ sendgrid: 'SG.' + _rnd(22) + '.' + _rnd(43),
8453
+ pem: '-----BEGIN RSA PRIVATE KEY-----\n' + _rnd(60) + '\n-----END RSA PRIVATE KEY-----',
8454
+ // END 줄이 없으면 BEGIN 만 지워지고 본문이 남았다(검수 실측). 붙여넣다 잘린 키가 흔하다.
8455
+ pemOpen: '-----BEGIN EC PRIVATE KEY-----\n' + _rnd(58) + '\n',
8456
+ // 암호화 키는 헤더 뒤 **빈 줄**이 오고 본문이 시작한다 — 빈 줄에서 멈추면 본문이 샌다(검수 실측).
8457
+ pemEnc: '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,9F8E7D6C\n\n' + _rnd(60) + '\n-----END RSA PRIVATE KEY-----',
8458
+ // 영문자만으로 된 32자 토큰도 자격증명이다(검수 실측: 그냥 샜다).
8459
+ bearerLetters: 'Bearer QwErTyUiOpAsDfGhJkLzXcVbNmQwErTy',
8460
+ // 구분자 없는 AWS 문맥 실값 — 문맥 패턴을 통째로 제외했더니 샜다(검수 실측).
8461
+ awsBare: 'aws secret access key ' + _rnd(40),
8462
+ };
8463
+ // 가림은 **탐지용 문맥 휴리스틱**을 그대로 쓰면 안 된다 — 정상 산문이 통째로 사라진다(검수 실측).
8464
+ // **공백이 있는 줄**(=문단)은 보존된다. 공백 없는 단일 토큰 줄과 20자 이상 Bearer 값은
8465
+ // 의도적으로 가린다 — 값의 조성으로는 시크릿과 산문을 가를 수 없고, 표시용 마스킹은 안전한 쪽으로 실패한다.
8466
+ const PROSE_OK = [
8467
+ 'Bearer 인증을 도입한다',
8468
+ 'Bearer token 을 쓴다',
8469
+ // BEGIN 을 인용한 정책 문서의 산문(Name: value 형태 포함)을 삼키면 안 된다(검수 실측).
8470
+ 'Note: 개인키는 커밋하지 않습니다',
8471
+ 'Status: 검토중',
8472
+ ];
8473
+ // 문맥 패턴은 **값만** 지운다 — 문장을 통째로 삼키던 것을 고쳤으므로 구조가 남아야 한다.
8474
+ const PROSE_STRUCT = ['결정: AWS secret commit ', ' was reverted.'];
8475
+ // plan.md 는 **표지 없는 정상 문서** — 본문이 그대로 실려야 한다(가드가 과잉이면 기능이 죽는다).
8476
+ fs.writeFileSync(path.join(H, 'plan.md'), '# Goal\n\n평범한 문장.\n' + PROSE_OK.join('\n') + '\n'
8477
+ + PROSE_STRUCT[0] + '0123456789abcdef0123456789abcdef01234567' + PROSE_STRUCT[1] + '\n');
8478
+ // skill-index.md 는 **표지가 있는 문서** — 본문을 아예 싣지 않아야 한다.
8479
+ // 값 단위 마스킹은 다섯 라운드 동안 계속 새어서(인용문 `> ` PEM · base64 의 `/`·`+` · 소문자 bearer ·
8480
+ // placeholder 모양 토큰) **안전의 전제에서 뺐다**. 표지가 있으면 통째로 제외한다.
8481
+ fs.writeFileSync(path.join(H, 'skill-index.md'), '# Skills\n\n' + Object.values(SEC).join('\n') + '\n');
8482
+ const N2 = 4000;
8483
+ fs.writeFileSync(path.join(H, 'decisions.md'), Array.from({ length: N2 }, (_, i) => '- D' + String(i + 1).padStart(4, '0') + ' 결정 — 근거와 대안을 함께 적는다').join('\n'));
8484
+ // 줄 길이를 들쭉날쭉하게 — 상한이 우연히 줄 끝에 맞으면 "원문 그대로 자르기" 변이가 살아남는다
8485
+ fs.writeFileSync(path.join(H, 'current-state.md'), Array.from({ length: N2 }, (_, i) => '- S' + String(i + 1).padStart(4, '0') + ' 상태 항목 — 충분히 길게 적어 상한을 넘긴다' + 'x'.repeat(i % 7)).join('\n'));
8486
+ // 이모지만 반복하면 상한이 서로게이트 쌍 경계에 정확히 맞아 쪼갤 자리가 없다 — 앞에 1글자를 끼운다
8487
+ // (skill-index 는 위에서 표지 문서로 쓰므로 서로게이트 검사는 tech-profile 로 옮긴다)
8488
+ fs.writeFileSync(path.join(H, 'tech-profile.json'), 'a' + '🎉'.repeat(G.DOC_PER_CAP));
8489
+ // 검수 지적: tech·progress 두 문서는 T2 에서 아무것도 확인하지 않아 경로가 끊겨도 통과했다.
8490
+ fs.writeFileSync(path.join(H, 'progress-tracker.md'), Array.from({ length: 300 }, (_, i) => '- P' + String(i + 1).padStart(4, '0') + ' 진행 기록').join('\n'));
8491
+ fs.rmSync(path.join(H, 'session-handoff.md'), { force: true });
8492
+ const D2 = G.buildDocsData(w, '2026-07-31T12:34:56.000Z');
8493
+ const g = (k) => D2.files.find(f => f.key === k) || {};
8494
+ const lone = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/;
8495
+ const _blob = JSON.stringify(D2);
8496
+ const T2 = D2.files.length === 7 && D2.at === '2026-07-31T12:34:56.000Z'
8497
+ // 표지 있는 문서: **본문을 싣지 않는다**. 부분 마스킹의 빈틈으로 새는 것을 구조적으로 막는다.
8498
+ && g('skills').guarded === true && g('skills').text === '' && g('skills').charsTotal > 0
8499
+ // 그리고 임베드 전체 어디에도 실값이 없어야 한다(문자열 전수 검사 — 어느 필드로 새든 잡힌다)
8500
+ && ![_dbPw, SEC.glpat, SEC.npm, SEC.stripe, SEC.sendgrid, SEC.bearerLetters.slice(7),
8501
+ SEC.pem.split('\n')[1], SEC.pemOpen.split('\n')[1], SEC.pemEnc.split('\n')[4], SEC.awsBare.slice(-40),
8502
+ ].some(s => _blob.includes(s))
8503
+ // 반대로 **표지 없는 문서는 그대로 실려야 한다** — 가드가 과잉이면 기능이 죽는다
8504
+ && g('plan').guarded !== true && g('plan').text.includes('평범한 문장.')
8505
+ && PROSE_OK.every(s => g('plan').text.includes(s))
8506
+ && PROSE_STRUCT.every(s => g('plan').text.includes(s))
8507
+ // 가드를 통과한 문서에도 **두 번째 층(마스킹)은 살아 있어야 한다** — 문맥 패턴의 값은 가려지고
8508
+ // 문장 구조는 남는다. 이 단언이 없으면 마스킹을 통째로 지워도 테스트가 통과한다(변이로 확인).
8509
+ && g('plan').text.includes('결정: AWS secret commit ***')
8510
+ && !g('plan').text.includes('0123456789abcdef0123456789abcdef01234567')
8511
+ // tech(JSON, 코드블록으로 표시) · progress(누적 로그, 뒤를 남김) 두 경로가 실제로 연결돼 있다
8512
+ && g('tech').lang === 'json' && g('tech').missing !== true
8513
+ && g('progress').tail === true && g('progress').text.includes('P0300') && g('progress').missing !== true
8514
+ && g('decisions').truncated === true && g('decisions').text.length <= G.DOC_PER_CAP
8515
+ && g('decisions').text.includes('D' + N2) && !g('decisions').text.includes('D0001')
8516
+ && g('decisions').text.split('\n').every(l => /^- D\d{4} /.test(l))
8517
+ && g('state').text.includes('S0001') && !g('state').text.includes('S' + N2)
8518
+ && g('state').truncated === true && g('state').text.split('\n').every(l => /^- S\d{4} 상태 항목 — 충분히 길게 적어 상한을 넘긴다x*$/.test(l))
8519
+ && !lone.test(g('tech').text) && g('tech').text.length <= G.DOC_PER_CAP // 서로게이트 검사는 tech 로 이동
8520
+ && g('handoff').missing === true && g('handoff').text === ''
8521
+ && !JSON.stringify(D2.files.map(f => f.rel)).includes(w.replace(/\\/g, '\\\\'));
8522
+ // 마스킹 규칙은 **격리된 입력**으로 직접 잰다. 한 문서에 여러 PEM 을 몰아넣으면 종료되지 않은 키가
8523
+ // 뒤쪽 END 줄과 짝지어져 서로를 가려 주고, 그 덕에 규칙이 깨져도 단언이 통과한다(변이 실험으로 확인).
8524
+ const PUx = require(path.resolve(path.dirname(CLI), '..', 'lib', 'pure-utils.js'));
8525
+ const encBody = _rnd(60), openBody = _rnd(58), letters = 'QwErTyUiOpAsDfGhJkLzXcVbNmQwErTy', awsVal = _rnd(40);
8526
+ const M = {
8527
+ // ① 암호화 키: 헤더 뒤 **빈 줄**이 오고 본문이 시작한다. END 없이도 본문이 가려져야 한다.
8528
+ encOpen: PUx.redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,9F8E\n\n' + encBody + '\n'),
8529
+ // ② END 없는 평문 키.
8530
+ plainOpen: PUx.redactSecrets('-----BEGIN EC PRIVATE KEY-----\n' + openBody + '\n'),
8531
+ // ③ BEGIN 을 인용한 정책 문서 — PEM 표준 헤더처럼 보이는 산문도 남아야 한다.
8532
+ prose: PUx.redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nComment: 이 키는 예시입니다\nNote: 커밋 금지\n일반 문장.'),
8533
+ // 판별용: 뒤에 base64 줄이 있어야 헤더 규칙이 실제로 발동한다. 표준 헤더(Comment)는 먹더라도
8534
+ // **비표준 헤더 형태의 산문**(Note:)에서 멈춰야 한다 — 아무 Name: value 나 먹으면 산문이 사라진다.
8535
+ proseB: PUx.redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nComment: 예시\nNote: 커밋 금지\n' + _rnd(40)),
8536
+ // ④ 종료되지 않은 키와 무관한 END 가 멀리 떨어져 있어도 그 사이 산문을 삼키면 안 된다.
8537
+ span: PUx.redactSecrets('-----BEGIN RSA PRIVATE KEY-----\n\n중간 산문입니다\n또 다른 줄\n\n-----END RSA PRIVATE KEY-----'),
8538
+ bearer: PUx.redactSecrets('Bearer ' + letters),
8539
+ // 길이 문턱(32자)으로 갈랐더니 20~31자 영문 토큰이 샜다(검수 실측) — 길이가 아니라 모양으로 가른다.
8540
+ bearer20: PUx.redactSecrets('Bearer AbCdEfGhIjKlMnOpQrSt'),
8541
+ bearer31: PUx.redactSecrets('Bearer AbCdEfGhIjKlMnOpQrStUvWxYzABCDE'),
8542
+ // 낱말 예외를 없앴다 — 유효한 불투명 토큰이 소문자/낱말꼴일 수 있어 예외가 곧 누출이었다(검수 실측).
8543
+ bearerWord: PUx.redactSecrets('Use Bearer authenticationMiddleware for API requests.'),
8544
+ bearerLower: PUx.redactSecrets('Bearer abcdefghijklmnopqrst'),
8545
+ bearerCamel: PUx.redactSecrets('Bearer CorrectHorseBatteryStaple'),
8546
+ bearerShort: PUx.redactSecrets('Bearer 인증을 도입한다'),
8547
+ // 카탈로그 문자군이 base64 의 `/` `+` 에서 끊겨 실토큰이 통째로 남았다(실측: 앞 8자 뒤 `/`).
8548
+ bearerSlash: PUx.redactSecrets('Bearer abcdefgh/ijklmnopqrstuvwxyz0123456789'),
8549
+ bearerPlus: PUx.redactSecrets('Bearer abcde+fghijklmnopqrstuvwxyz0123456789'),
8550
+ // BEGIN 인용 뒤의 **평범한 영문 식별자** 한 줄을 본문으로 오인해 지웠다(검수 실측).
8551
+ // 그렇다고 길이로 올리면 줄이 짧게 감긴 키 본문이 샌다 — 내용(숫자/기호 포함)으로 가른다.
8552
+ quoteWord: PUx.redactSecrets('정책 인용:\n-----BEGIN RSA PRIVATE KEY-----\nNeverCommitPrivateKeys\n다음 문단'),
8553
+ shortBody: PUx.redactSecrets('-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA7x9fQ2\nkLmN0pQrStUvWxYz\n-----END RSA PRIVATE KEY-----'),
8554
+ // PGP 블록은 END 앞에 =CRC 줄이 온다 — 허용하지 않으면 CRC 와 END 가 남는다.
8555
+ pgp: PUx.redactSecrets('-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: GnuPG v2\n\n' + _rnd(64) + '\n=AbCd\n-----END PGP PRIVATE KEY BLOCK-----'),
8556
+ awsBare: PUx.redactSecrets('aws secret access key ' + awsVal),
8557
+ awsProse: PUx.redactSecrets('결정: AWS secret commit 0123456789abcdef0123456789abcdef01234567 was reverted.'),
8558
+ };
8559
+ const T2b = !M.encOpen.includes(encBody) && !M.plainOpen.includes(openBody)
8560
+ && M.prose.includes('Comment: 이 키는 예시입니다') && M.prose.includes('Note: 커밋 금지') && M.prose.includes('일반 문장.')
8561
+ && M.proseB.includes('Note: 커밋 금지')
8562
+ && M.span.includes('중간 산문입니다') && M.span.includes('또 다른 줄')
8563
+ && !M.bearer.includes(letters)
8564
+ && !M.bearer20.includes('AbCdEfGhIjKlMnOpQrSt') && !M.bearer31.includes('AbCdEfGhIjKlMnOpQrStUvWxYzABCDE')
8565
+ // 소문자만·낱말꼴 토큰도 가려야 한다(예외를 두면 그 예외가 누출 경로가 된다)
8566
+ && !M.bearerLower.includes('abcdefghijklmnopqrst') && !M.bearerCamel.includes('CorrectHorseBatteryStaple')
8567
+ && M.bearerWord === 'Use *** for API requests.' // 산문도 함께 가려지는 것이 **의도한 절충**
8568
+ && M.bearerShort === 'Bearer 인증을 도입한다' // 20자 미만은 영향 없음
8569
+ && M.bearerSlash === '***' && M.bearerPlus === '***' // base64 의 / + 에서 끊기면 안 된다
8570
+ // BEGIN 뒤 공백 없는 단일 토큰 줄은 가려진다(의도) — 그러나 **문단**은 남아야 한다
8571
+ && !M.quoteWord.includes('NeverCommitPrivateKeys') && M.quoteWord.includes('다음 문단')
8572
+ && !M.shortBody.includes('MIIEowIBAAKCAQEA7x9fQ2') && !M.shortBody.includes('kLmN0pQrStUvWxYz')
8573
+ && M.pgp === '***'
8574
+ && !M.awsBare.includes(awsVal)
8575
+ && M.awsProse.includes('결정: AWS secret commit ') && M.awsProse.includes(' was reverted.');
8576
+ // 표지 술어는 **표지 하나씩 격리**해서 잰다. 한 파일에 여러 시크릿을 몰아넣으면 다른 표지가 대신
8577
+ // 가드해 줘서, 특정 표지를 무력화하는 변이가 그대로 통과한다(변이 실험으로 확인).
8578
+ // 표지는 값이 아니라 **형식**으로 정의했으므로 줄 앞 접두어·대소문자·문자군 우회가 모두 막혀야 한다.
8579
+ const HC = PUx.hasCredentialMarker;
8580
+ const T2c = [
8581
+ '-----BEGIN RSA PRIVATE KEY-----',
8582
+ '> -----BEGIN RSA PRIVATE KEY-----', // 인용문 접두(검수가 재현한 전량 노출 경로)
8583
+ '- -----BEGIN EC PRIVATE KEY-----', // 목록 접두
8584
+ ' -----BEGIN PGP PRIVATE KEY BLOCK-----', // 들여쓰기
8585
+ 'bearer dddddddddddddddddddddddd', // 소문자 스킴
8586
+ 'Bearer /cccccccccccccccccccccccc', // base64 의 / 로 시작
8587
+ 'Bearer aaaaaaaaaaaaaaaaaaa~ZZZZZZZZZZZZZZZZZZZZ', // token68 의 ~
8588
+ // 특수문자를 **가운데** 두고 양쪽 조각을 16자 미만으로 — 문자군에서 `+ / ~` 를 빼면 여기서 갈린다.
8589
+ // 맨 앞에 두면 접두 허용(`[^A-Za-z0-9]{0,4}`)이 흡수해 뒤 조각만으로 매치돼 변이가 살아남는다(실측).
8590
+ 'Bearer abcdefghij/klmnopqrst', 'Bearer abcdefghij+klmnopqrst', 'Bearer abcdefghij~klmnopqrst',
8591
+ 'Authorization: Basic YTpi',
8592
+ // 검수가 표지를 빠져나가는 네 형태를 보였다 — 짧은 RFC6750 토큰 · 쿠키 · 5분절 JWE · 강조로 감싼 스킴.
8593
+ 'Bearer mF_9.B5f-4.1JqM_x', // 19자(RFC6750 은 길이를 보장하지 않는다)
8594
+ 'Cookie: session=s%3AabcdefghijklmnopqrstuvwxyzAB',
8595
+ 'Set-Cookie: sid=abcdefghijklmnopqrstuvwxyz; HttpOnly',
8596
+ 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..48V1_ALb6US0.7gg.XFBoMYUZodetZdvTiFvSkQ', // compact JWE
8597
+ '**Bearer** eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghij',
8598
+ '`Bearer` AbCdEfGhIjKlMnOpQrStUvWx',
8599
+ 'glpat-abcdefghijklmnopqrstuvwx', 'npm_' + _rnd(36), 'ghp_' + _rnd(36), 'AKIA' + _rnd(16),
8600
+ 'DATABASE_URL=postgres://alice:pw123456@db/app',
8601
+ 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc',
8602
+ ].every(s => HC(s) === true)
8603
+ // 반대로 평범한 문서는 표지가 없어야 한다 — 여기서 참이 되면 문서가 통째로 안 보인다
8604
+ && ['# 제목\n\n첫 문단입니다.', '- 목록 항목\n- 또 다른 항목', 'Bearer 인증을 도입한다',
8605
+ 'https://example.com/docs/keys 를 참고하세요', 'password 정책을 문서화한다',
8606
+ '결정: 인증 방식을 변경한다', 'BEGIN 과 END 사이를 확인하라', 'cookie 정책을 정리한다',
8607
+ 'authorization 흐름을 검토한다'].every(s => HC(s) === false);
8608
+ dbg.marker = String(T2c);
8609
+
8610
+ // ── T2d: **문서 탭만 가드하면 소용없다.** 같은 내용이 그래프 노드·로드맵으로도 임베드된다 —
8611
+ // `decision add "Authorization: Bearer <토큰>"` 이 DATA.nodes[].label / detail 로 원문 그대로 실렸다(검수 실측).
8612
+ // 임베드 직전 한 곳에서 모든 문자열을 통과시키므로, **실제 CLI 경로**로 어느 표면으로든 새지 않는지 확인한다.
8613
+ const w2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-gph96-')); _d.push(w2);
8614
+ fs.writeFileSync(path.join(w2, 'package.json'), JSON.stringify({ name: 'g96', version: '0.1.0' }, null, 2));
8615
+ const L2 = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', timeout: 180000, cwd: w2 });
8616
+ L2(['init', w2, '--yes', '--no-stale-check']);
8617
+ const NEEDLE = { decision: 'DECISIONLEAK_' + _rnd(20), task: 'TASKLEAK_' + _rnd(20), plan: 'PLANLEAK_' + _rnd(20) };
8618
+ L2(['decision', 'add', 'Authorization: Bearer ' + NEEDLE.decision, '--reason', 'audit', '--path', w2]);
8619
+ // **표지만 잡고 마스킹은 못 잡는 형태**로도 확인한다 — 쿠키는 키 이름에 KEY/TOKEN 이 없어
8620
+ // 마스킹 규칙에 걸리지 않는다. 이게 없으면 "표지 가드 제거" 변이가 마스킹에 가려 살아남는다(실측).
8621
+ L2(['task', 'add', 'Cookie: session=' + NEEDLE.task, '--path', w2]);
8622
+ L2(['plan', 'add', 'Set-Cookie: sid=' + NEEDLE.plan, '--path', w2]);
8623
+ L2(['task', 'add', '평범한 작업 항목입니다', '--path', w2]);
8624
+ L2(['graph', w2, '--html']);
8625
+ const gHtml = fs.readFileSync(path.join(w2, 'leerness.html'), 'utf8');
8626
+ // **출구 전수 조사** — "고친 출구 하나만 보고 안전을 선언"한 실수를 되풀이하지 않으려면 출구를 세어야 한다.
8627
+ // 사용자 텍스트가 leerness.html 로 나가는 경로를 하나씩 오염시켜, 어느 하나라도 새면 실패한다.
8628
+ // 표지는 **마스킹이 못 잡는 쿠키 형태**로 심는다(마스킹에 가려 통과하는 것을 막는다).
8629
+ const H2 = path.join(w2, '.harness');
8630
+ const SURF = [
8631
+ ['lesson', () => L2(['lesson', 'save', 'Cookie: session=LEAKLESSON_QwErTyUiOpAsDfGh', '--path', w2])],
8632
+ ['rule', () => L2(['rule', 'add', 'Cookie: session=LEAKRULE_QwErTyUiOpAsDfGh', '--trigger', 'every-round', '--path', w2])],
8633
+ ['planfile', () => fs.writeFileSync(path.join(H2, 'plan.md'), '# Goal\n\nCookie: session=LEAKPLANFILE_QwErTyUiOpAsDfGh\n')],
8634
+ ['progress', () => fs.appendFileSync(path.join(H2, 'progress-tracker.md'), '\n- Cookie: session=LEAKPROGRESS_QwErTyUiOpAsDfGh\n')],
8635
+ ['state', () => fs.writeFileSync(path.join(H2, 'current-state.md'), 'Cookie: session=LEAKSTATE_QwErTyUiOpAsDfGh\n')],
8636
+ ['handoff', () => fs.writeFileSync(path.join(H2, 'session-handoff.md'), 'Cookie: session=LEAKHANDOFF_QwErTyUiOpAsDfGh\n')],
8637
+ ['skills', () => fs.writeFileSync(path.join(H2, 'skill-index.md'), 'Cookie: session=LEAKSKILLS_QwErTyUiOpAsDfGh\n')],
8638
+ ['feature', () => fs.writeFileSync(path.join(H2, 'feature-graph.md'), '## F-0001 Cookie: session=LEAKFEATURE_QwErTyUiOpAsDfGh\n- depends-on: F-0002\n')],
8639
+ ['tech', () => { const p = path.join(H2, 'tech-profile.json'); let jj = {}; try { jj = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {} jj.current = jj.current || {}; jj.current.services = [{ id: 'svc', evidence: 'Cookie: session=LEAKTECH_QwErTyUiOpAsDfGh' }]; fs.writeFileSync(p, JSON.stringify(jj, null, 2)); }],
8640
+ // **깊이 상한을 두면 그 너머가 통째로 우회한다.** tech-profile.json 은 임의 JSON 이라 실 CLI 로 도달 가능하다
8641
+ // (검수 실측: 12단계 밖 문자열에 정규화가 아예 걸리지 않았다). 얕은 픽스처만으로는 이 변이를 못 잡는다.
8642
+ ['deep', () => {
8643
+ const p = path.join(H2, 'tech-profile.json');
8644
+ let jj = {}; try { jj = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
8645
+ let node = { evidence: 'Cookie: session=LEAKDEEP_QwErTyUiOpAsDfGh' };
8646
+ for (let k = 0; k < 40; k++) node = { w: node };
8647
+ jj.deep = node;
8648
+ fs.writeFileSync(p, JSON.stringify(jj, null, 1));
8649
+ }],
8650
+ ];
8651
+ for (const [, fn] of SURF) fn();
8652
+ // **표지 없는 문서 하나**는 반드시 남겨 둔다 — 전부 가드되면 임베드 텍스트가 0 이라
8653
+ // "보고 spent = 실제 바이트" 불변식이 공허하게 참이 된다(변이 실험으로 확인).
8654
+ // 내용은 `redactSecrets` 가 **비멱등**인 형태로 둔다(`auth_scheme=***,` 의 쉼표를 2회차에 먹는다 — 실측).
8655
+ // 그래야 "임베드 직전에 다시 변환" 변이가 회계 어긋남으로 드러난다.
8656
+ fs.writeFileSync(path.join(H2, 'decisions.md'),
8657
+ '# 결정\n\n설계 메모: idempotency_key=run:phase:step, auth_scheme=\'\', X_AUTH=false 를 확인한다.\n정상 문단입니다.\n');
8658
+ L2(['graph', w2, '--html']);
8659
+ const gHtml2 = fs.readFileSync(path.join(w2, 'leerness.html'), 'utf8');
8660
+ const T2d = Object.values(NEEDLE).every(v => !gHtml.includes(v))
8661
+ && gHtml.includes('평범한 작업 항목입니다') // 가드가 과잉이면 그래프가 비어 버린다
8662
+ && SURF.every(([n]) => !gHtml2.includes('LEAK' + n.toUpperCase() + '_QwErTyUiOpAsDfGh'))
8663
+ && gHtml2.includes('평범한 작업 항목입니다')
8664
+ // **보고한 spent = 실제 임베드 바이트** 불변식. 문서 텍스트를 임베드 직전에 다시 변환하면
8665
+ // 회계만 옛 값으로 남아 어긋난다(검수 실측: 1바이트). 생성물에서 직접 확인한다.
8666
+ && (function () {
8667
+ const gi = gHtml2.indexOf('var DATA = '), gj = gHtml2.indexOf(';\n', gi);
8668
+ const gd = JSON.parse(gHtml2.slice(gi + 11, gj).replace(/\\u003c/g, '<'));
8669
+ const sum = (gd.docs.files || []).reduce((a, f) => a + Buffer.byteLength(f.text || '', 'utf8'), 0);
8670
+ return gd.docs.spent === sum && sum > 0; // sum===0 이면 불변식이 공허하게 참이다
8671
+ })();
8672
+ // 임베드 정규화의 **경계 3종** — 주입 API 로만 만들 수 있는 형태까지 포함한다.
8673
+ // ① 공유 참조(순환 아님)를 지우면 정상 데이터가 조용히 사라진다(방문 집합을 전역으로 두면 그렇게 된다).
8674
+ // ② 진짜 순환은 끊고 생성은 성공해야 한다. ③ 아주 깊은 유효 JSON 으로 **생성 자체가 실패하면 안 된다**
8675
+ // — 상한이 fail-open(원본 통과)이면 우회, fail-safe(자리표시자 대체)면 보호다. 그 구분을 여기서 못 박는다.
8676
+ const shared = { id: 'svc', evidence: 'SHAREDMARK' };
8677
+ const dShared = G.buildGraphData(w2, { _loadTechProfile: () => ({ current: { languages: [shared], services: [shared] }, history: [] }) });
8678
+ const cyc = { evidence: 'CYCMARK' }; cyc.self = cyc;
8679
+ let cycOk = false;
8680
+ try {
8681
+ const cs = JSON.stringify(G.buildGraphData(w2, { _loadTechProfile: () => ({ current: { languages: [cyc], services: [] }, history: [] }) }));
8682
+ // 순환은 **곧바로** 끊겨야 한다. 깊이 상한이 대신 끊어 주면 결과는 나오지만 200단계가 부풀어 실린다
8683
+ // — 크기까지 봐야 "순환 차단 제거" 변이가 판별된다(그렇지 않으면 살아남았다).
8684
+ cycOk = cs.includes('CYCMARK') && (cs.match(/"self"/g) || []).length <= 1;
8685
+ } catch { cycOk = false; }
8686
+ let deep = { evidence: 'DEEPMARK' };
8687
+ for (let k = 0; k < 5000; k++) deep = { w: deep };
8688
+ let deepOk = false;
8689
+ try { deepOk = typeof JSON.stringify(G.buildGraphData(w2, { _loadTechProfile: () => ({ current: { languages: [], services: [] }, history: [], deep }) })) === 'string'; } catch { deepOk = false; }
8690
+ // 상한은 **넉넉해야** 한다 — 낮추면 누출은 없지만 정상 데이터가 조용히 사라진다.
8691
+ // "생성 성공"만 보면 상한을 12 로 낮추는 변이가 통과한다(실측). 보통 깊이의 값이 남는지도 본다.
8692
+ let mid = { evidence: 'MIDMARK' };
8693
+ for (let k = 0; k < 30; k++) mid = { w: mid };
8694
+ let midOk = false;
8695
+ try { midOk = JSON.stringify(G.buildGraphData(w2, { _loadTechProfile: () => ({ current: { languages: [], services: [] }, history: [], mid }) })).includes('MIDMARK'); } catch { midOk = false; }
8696
+ deepOk = deepOk && midOk;
8697
+ const T2e = (JSON.stringify(dShared.tech).match(/SHAREDMARK/g) || []).length === 2 && cycOk && deepOk;
8698
+ dbg.embed = String(T2e);
8699
+ dbg.graph = String(T2d) + ' (출구 ' + (SURF.length + 3) + '종)';
8700
+ dbg.mask = String(T2b);
8701
+ dbg.data = String(T2) + ` (dec ${g('decisions').text.length}/${g('decisions').totalChars} · skills ${g('skills').text.length})`;
8702
+
8703
+ // ── T3: 전체 상한에서 **조용한 누락이 없다**.
8704
+ // 기본값(7종 × 64KB = 448KB)으로는 512KB 상한에 닿을 수 없어 이 분기가 아예 안 돈다 —
8705
+ // 작은 상한을 주입해 실제로 발화시키고, "발화했다"까지 단언한다(안 그러면 every 가 공허하게 참).
8706
+ // ASCII 픽스처만 쓰면 예산을 **코드 단위로 세는 회귀**가 그대로 통과한다(검수 실측) — 한글을 섞는다.
8707
+ for (const f of ['plan.md', 'skill-index.md', 'current-state.md', 'progress-tracker.md', 'decisions.md']) {
8708
+ fs.writeFileSync(path.join(H, f), ('한글이 섞인 줄 ' + 'x'.repeat(180) + '\n').repeat(400));
8709
+ }
8710
+ const S2 = G.buildDocsData(w, 'x', { perCap: 4096, totalCap: 9000 });
8711
+ // 보고된 spent 가 **실제 바이트**와 같아야 한다 — 코드 단위로 세면 여기서 어긋난다.
8712
+ const S2bytes = S2.files.reduce((a, f) => a + Buffer.byteLength(f.text || '', 'utf8'), 0);
8713
+ const sk2 = S2.files.filter(x => x.skipped);
8714
+ // 상한은 **UTF-8 바이트**여야 한다. 코드 단위로 세면서 "64KB"라 적으면 한글 문서에서 3배가 실린다
8715
+ // (검수 실측: 합계 512KB 주장 → 실제 1,152KB · HTML 1.21MB).
8716
+ const KO = '한'.repeat(30000);
8717
+ for (const f of ['plan.md', 'skill-index.md', 'current-state.md', 'progress-tracker.md', 'decisions.md', 'tech-profile.json']) {
8718
+ fs.writeFileSync(path.join(H, f), KO);
8719
+ }
8720
+ const B2 = G.buildDocsData(w, 'x');
8721
+ const byteSum = B2.files.reduce((a, f) => a + Buffer.byteLength(f.text || '', 'utf8'), 0);
8722
+ const perByteOk = B2.files.every(f => Buffer.byteLength(f.text || '', 'utf8') <= G.DOC_PER_CAP);
8723
+ // 경계에 **빈 줄**이 하나만 있어도 keep 은 1 이 되지만 담긴 내용은 0자였다 — 초장문 문서가 통째로
8724
+ // 빈 화면이 됐다(검수 실측 70,000자 → 0자). 앞뒤 양쪽 방향으로 확인한다.
8725
+ // 빈 줄 **한 개**만 보면 두 개에서 재발하는 것을 못 잡는다(검수 실측: 1개는 통과, 2개는 1자로 유실).
8726
+ const LONG = 'a'.repeat(70000);
8727
+ const capHead = G._capDoc('\n' + LONG, 65536, false);
8728
+ const capTail = G._capDoc(LONG + '\n', 65536, true);
8729
+ const capHead2 = G._capDoc('\n\n' + LONG, 65536, false);
8730
+ const capTail2 = G._capDoc(LONG + '\n\n', 65536, true);
8731
+ const capHead3 = G._capDoc('\n\n\n\n' + LONG, 65536, false);
8732
+ // 빈 줄의 "내용" 을 세는 방식은 세 번 틀렸다 — CRLF 의 \r, 공백, 탭이 모두 내용으로 세어졌다(검수 실측).
8733
+ // 이제 줄 단위 결과와 코드포인트 절단 결과 중 **긴 쪽**을 쓰므로 어떤 공백 조합에도 예산을 채운다.
8734
+ const capWs = [
8735
+ G._capDoc('\r\n' + LONG, 65536, false), G._capDoc('\r\n\r\n' + LONG, 65536, false),
8736
+ G._capDoc(' \n' + LONG, 65536, false), G._capDoc('\t\n' + LONG, 65536, false),
8737
+ G._capDoc(LONG + '\r\n', 65536, true), G._capDoc(LONG + '\n ', 65536, true),
8738
+ ];
8739
+ // 코드포인트 고지: **이모지** 문서라야 판별된다 — 한글은 UTF-16 길이와 코드포인트 수가 같아
8740
+ // 고지를 String.length 로 되돌리는 변이가 그대로 통과한다(검수 실측).
8741
+ fs.writeFileSync(path.join(H, 'current-state.md'), '🎉'.repeat(30000));
8742
+ const emo = G.buildDocsData(w, 'x');
8743
+ const anyDoc = emo.files.find(f => f.key === 'state') || {};
8744
+ const T3 = sk2.length > 0 && S2.spent <= 9000 && S2.perCap === 4096 && S2.totalCap === 9000
8745
+ // 화면 고지는 charsTotal(코드포인트)을 쓴다 — totalChars 만 보면 고지를 0 으로 바꿔도 통과한다(검수 실측).
8746
+ && sk2.every(x => x.text === '' && x.totalChars > 0 && x.charsTotal > 0 && x.truncated === true)
8747
+ // **정확히 상한**인 문서를 "잘렸다"고 표시하면 안 된다(경계 오판) — 잡히지 않던 변이다.
8748
+ && G._capDoc('a'.repeat(65536), 65536, false).truncated === false
8749
+ && G._capDoc('a'.repeat(65537), 65536, false).truncated === true
8750
+ // minSlice 를 사실상 없애면 **의미 없는 조각**이 실린다 — skipped 개수가 아니라 "조각이 없음"을 단언한다.
8751
+ && S2.files.every(x => x.skipped || x.missing || x.guarded || Buffer.byteLength(x.text || '', 'utf8') >= 256)
8752
+ && S2.files.filter(x => !x.missing).every(x => x.skipped || x.truncated !== undefined)
8753
+ && perByteOk && byteSum <= G.DOC_TOTAL_CAP && byteSum > G.DOC_PER_CAP // 실제로 여러 문서가 실렸다
8754
+ && S2.spent === S2bytes // 보고 예산 = 실제 바이트
8755
+ && [capHead, capTail, capHead2, capTail2, capHead3, ...capWs].every(c => c.text.replace(/\s/g, '').length > 60000 && Buffer.byteLength(c.text, 'utf8') <= 65536)
8756
+ // "앞부분만/뒷부분만" 고지가 참이어야 한다 — 경계 줄이 길다고 제목·푸터를 버리면 안 된다(검수 실측).
8757
+ && G._capDoc('TITLE\n' + LONG, 65536, false).text.startsWith('TITLE\n')
8758
+ && G._capDoc(LONG + '\nFOOTER', 65536, true).text.endsWith('\nFOOTER')
8759
+ && !/\r$/.test(G._capDoc(LONG + '\r\nFOOTER', 65536, true).text)
8760
+ // `\n` 으로만 나누므로 CRLF 문서는 줄 끝에 `\r` 이 남는다 — head 에서 다음 줄이 안 들어가면
8761
+ // 재조립 때 개행이 붙지 않아 **고립된 CR** 이 꼬리에 남았다(검수 실측: "A\r").
8762
+ // 경계 줄이 **상한 안에 들어가는** 경우라야 판별된다 — 초장문이면 잘린 조각이 이어붙어
8763
+ // CR 뒤에 개행이 생기고 고립되지 않는다(그 픽스처로는 변이가 살아남았다).
8764
+ && !/\r(?!\n)/.test(G._capDoc('A\r\nBB\r\nC', 4, false).text)
8765
+ && !/\r(?!\n)/.test(G._capDoc('AAAA\r\n' + LONG, 65536, false).text)
8766
+ && Buffer.byteLength(G._capDoc('TITLE\n' + LONG, 65536, false).text, 'utf8') <= 65536
8767
+ // 이모지 문서에서 코드포인트 수는 UTF-16 길이의 **절반**이다 — 두 값이 달라야 판별력이 있다.
8768
+ && anyDoc.chars === Array.from(anyDoc.text || '').length && anyDoc.chars * 2 === (anyDoc.text || '').length
8769
+ && anyDoc.charsTotal === 30000 && anyDoc.totalChars === 60000
8770
+ // 데이터가 코드포인트를 실어도 **화면 고지가 String.length 로 되돌아가면** 사용자가 보는 수치는 다시 틀린다.
8771
+ // 그 변이는 데이터 단언만으로는 살아남았다(실측) — 고지가 어느 값을 쓰는지 생성물에서 확인한다.
8772
+ && /'\+\(f\.chars\|\|0\)\+'\/'\+\(f\.charsTotal\|\|0\)\+'자\(/.test(html)
8773
+ && !/\(f\.text\|\|''\)\.length\+'\/'/.test(html);
8774
+ dbg.cap = String(T3) + ` (skipped ${sk2.length} · spent ${S2.spent}/${S2bytes} · 바이트합 ${byteSum} · 빈경계 ${capHead.text.length}/${capTail.text.length}/${capHead2.text.length}/${capTail2.text.length})`;
8775
+
8776
+ ok = setupOk && T1 && T2 && T2b && T2c && T2d && T2e && T3;
8777
+ if (!ok) console.log(` [P-0010 디버그] 셋업=${setupOk} 렌더=${dbg.render} 데이터=${dbg.data} 표지=${dbg.marker} 그래프=${dbg.graph} 임베드=${dbg.embed} 마스킹=${dbg.mask} 상한=${dbg.cap}`);
8778
+ } catch (e) { console.log(' [P-0010 디버그] 예외: ' + ((e && e.message) || e)); }
8779
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8780
+ console.log(ok ? '✓ B(1.36.96) P-0010 문서 탭: 최소 부분집합 렌더(제목·목록·펜스·표·링크·강조) · XSS 8방향 차단(원시HTML·javascript/data/vbscript/프로토콜상대·속성탈출·표/제목/링크텍스트) · 펜스 안 미해석 · **자격증명 표지 문서는 본문 미포함**(마스킹은 두 번째 층) · 누적로그 tail/일반 head · 줄경계+코드포인트 절단 · 결측/전체상한 명시 고지' : '✗ P-0010 문서 탭 실패');
8781
+ if (!ok) failed++;
8782
+ }
8783
+
8329
8784
  // 1.36.94 (헌트 이월 2건 + 자체 검수 반영): ① 형상 무효 스토어에서 **차단 메시지가 안내한 탈출구가
8330
8785
  // 같은 사유로 거부**돼, 남는 실행 가능한 선택지가 "보호를 끄기" 하나였다.
8331
8786
  // ② `provider add codex --bin codex` 라는 사실상 무변경 재등록이 빌트인 authCheck 를 지워 인증 축을