leerness 1.36.95 → 1.36.97

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/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 를 지워 인증 축을
@@ -8946,6 +9401,60 @@ total++;
8946
9401
  if (!ok) failed++;
8947
9402
  }
8948
9403
 
9404
+ // ── 1.36.97 (P-0011): 렌즈 3축 심화 — CLI 표면 행위. selftest 는 함수만 보므로 실제 렌더/로케일/완료검증 인라인은 여기서 잰다.
9405
+ {
9406
+ total++;
9407
+ let ok = false; const dbg = {};
9408
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lens97-'));
9409
+ const R = (a, o = {}) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: o.cwd || d, encoding: 'utf8', timeout: 60000 });
9410
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9411
+ const HAN = /[가-힣]/;
9412
+ try {
9413
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9414
+ // ① 심화 렌즈가 실제로 렌더되고 12문항이 다 나오는가(카탈로그에만 있고 표면에 안 나오면 없는 것과 같다)
9415
+ const c = out(R(['lens', 'contract', '--path', d]));
9416
+ const contractOk = /## contract/.test(c) && /12\. /.test(c) && !/13\. /.test(c) && /breaking/.test(c);
9417
+ const rc = out(R(['lens', 'recovery', '--path', d]));
9418
+ const recOk = /## recovery/.test(rc) && /fail-open/.test(rc) && /12\. /.test(rc);
9419
+ const ob = out(R(['lens', 'observability', '--path', d]));
9420
+ const obsOk = /## observability/.test(ob) && /민감정보/.test(ob) && /12\. /.test(ob);
9421
+ const ax = out(R(['lens', 'axes', '--path', d]));
9422
+ const axesOk = /## axes/.test(ax) && /8\. /.test(ax) && !/9\. /.test(ax) && /leerness lens database/.test(ax);
9423
+ // ② 알 수 없는 도메인 안내가 신규 축까지 광고하는가(파생 목록이 표면에 실제로 도달했는지 — 하드코딩이면 여기서 빠진다)
9424
+ const un = R(['lens', 'nosuchaxis', '--path', d]);
9425
+ const unknownOk = un.status === 1 && /observability/.test(out(un)) && /contract/.test(out(un)) && /axes/.test(out(un));
9426
+ // ②-b 도메인 목록을 손으로 적던 세 표면(help ko · help en · commands)이 카탈로그에서 파생되는가.
9427
+ // 위 unknownOk 는 lensCmd 자체 목록이라 이 셋의 낡음을 못 본다 — 절제 시험에서 실제로 통과해 버렸다.
9428
+ const surfaces = [out(R(['commands', '--path', d])), out(R(['--help'])), out(R(['--help', '--language', 'en']))];
9429
+ const surfaceOk = surfaces.every(s => /lens \[/.test(s) && /observability/.test(s) && /\|axes\]/.test(s));
9430
+ // ③ en 로케일 누수 0 — 한글로만 확인하면 영문 경로 구멍을 통째로 놓친다(1.36.89 재발 방지)
9431
+ const en = out(R(['lens', 'recovery', '--language', 'en', '--path', d]));
9432
+ const enOk = /failure & recovery/.test(en) && /fail-open/.test(en) && !HAN.test(en);
9433
+ // ④ 완료 검증 인라인: 경로에 단서 없는 파일이라도 '내용'으로 소환되는가 + 대조군은 조용한가
9434
+ fs.writeFileSync(path.join(d, 'worker.mjs'), 'export async function run(){ try { await go() } catch (e) {} }\n');
9435
+ fs.writeFileSync(path.join(d, 'pure.mjs'), 'export const add = (a, b) => a + b\n');
9436
+ fs.writeFileSync(path.join(d, 'loop.mjs'), 'export async function all(ids){ for (const id of ids) { await fetchOne(id) } }\n');
9437
+ const mk = (title, file) => {
9438
+ const j = R(['task', 'add', title, '--json']);
9439
+ let id = ''; try { id = (JSON.parse(j.stdout || '{}').task || {}).id || ''; } catch {}
9440
+ if (!id) { const m = /(T-\d+)/.exec(out(j)); id = m ? m[1] : ''; }
9441
+ R(['task', 'update', id, '--status', 'done', '--evidence', `구현 ${file} 수정`]);
9442
+ return out(R(['verify-claim', id, '--path', d]));
9443
+ };
9444
+ const vRec = mk('워커 정리', 'worker.mjs');
9445
+ const vPure = mk('순수 헬퍼', 'pure.mjs');
9446
+ const vLoop = mk('일괄 조회', 'loop.mjs');
9447
+ const recallOk = /· recovery\(/.test(vRec) && !/· recovery\(/.test(vPure) && /· code\(/.test(vPure);
9448
+ // ⑤ 경량 축은 신호가 있을 때만 한 줄 — 모든 변경에 붙으면 곧 읽히지 않는 배경음이 된다
9449
+ const axisOk = /· axes\(/.test(vLoop) && /성능·확장성/.test(vLoop) && !/· axes\(/.test(vPure);
9450
+ Object.assign(dbg, { contractOk, recOk, obsOk, axesOk, unknownOk, surfaceOk, enOk, recallOk, axisOk });
9451
+ ok = contractOk && recOk && obsOk && axesOk && unknownOk && surfaceOk && enOk && recallOk && axisOk;
9452
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9453
+ console.log(ok ? '✓ B(1.36.97/P-0011) 렌즈 3축 심화: contract/recovery/observability 12문항 렌더 + axes 8축 + 파생 도메인 목록 + en 누수0 + 내용기반 소환(대조군 침묵) + 축 힌트 조건부'
9454
+ : '✗ 1.36.97 렌즈 3축 심화 실패 ' + JSON.stringify(dbg));
9455
+ if (!ok) failed++;
9456
+ }
9457
+
8949
9458
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
8950
9459
  if (failed > 0) process.exit(1);
8951
9460