leerness 1.36.94 → 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 +137 -0
- package/README.md +4 -4
- package/bin/leerness.js +69 -57
- package/lib/graph.js +287 -5
- package/lib/pure-utils.js +121 -5
- package/package.json +1 -1
- package/scripts/e2e.js +613 -0
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
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 이 & → & 팽창으로
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -859,9 +969,15 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
|
|
|
859
969
|
if (!l || nextLines.has(l)) continue;
|
|
860
970
|
const _ac = altCounts.get(l);
|
|
861
971
|
if (_ac > 0) { altCounts.set(l, _ac - 1); continue; } // canonical 출현분만 소비 (복제 커스텀 생존)
|
|
862
|
-
// 1.36.60 (
|
|
863
|
-
//
|
|
864
|
-
|
|
972
|
+
// 1.36.60 의 자동생성 이력 필터(`- .*MCP **N 도구` · `- MCP **N` · `- 🏆 마일스톤` · `- 🎉 **N 라운드`)는
|
|
973
|
+
// 1.36.95 에서 **삭제했다**. "사용자 산문이 이 패턴과 겹칠 확률은 무시 가능"이라고 적혀 있었지만
|
|
974
|
+
// 검수가 겹치는 실제 문장으로 삭제를 재현했다:
|
|
975
|
+
// `- 프로젝트 정책: MCP **3 도구는 별도 승인 후 사용한다` · `- 🏆 마일스톤은 고객 승인 뒤에만 확정한다`
|
|
976
|
+
// 이번 라운드가 템플릿에서 이력 43줄을 지우면서 그 줄들이 처음으로 이 경로를 타게 됐고,
|
|
977
|
+
// 실측에서 설치본 21개의 이력 731줄 중 **346줄이 삭제**됐으며 사용자 문장
|
|
978
|
+
// `- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지` 도 함께 사라졌다.
|
|
979
|
+
// 이 릴리스는 "삭제하지 않는 변경만" 담기로 했으므로 필터를 걷어낸다 — 이력 줄은 Preserved 로 이월된다.
|
|
980
|
+
// 과보존은 이 파일의 계약상 무해하고(false-DROP 이 버그), 기존 설치본 정리는 provenance 라운드로 미룬다.
|
|
865
981
|
// preserved 래퍼 자체의 보일러플레이트는 이월 대상 아님 (구 <details> 형식 + 신 평문 형식 + en 래퍼 모두)
|
|
866
982
|
if (l === tag || l === '---' || l === '<details>' || l === '</details>') continue;
|
|
867
983
|
if (/^```/.test(l) || /^<summary>/.test(l)) continue;
|
|
@@ -1315,7 +1431,7 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
|
|
|
1315
1431
|
module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
|
|
1316
1432
|
_parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
|
|
1317
1433
|
_matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
|
|
1318
|
-
_isSecretKey, redactSecrets, compareVer, parseHarnessVersion,
|
|
1434
|
+
_isSecretKey, redactSecrets, hasCredentialMarker, compareVer, parseHarnessVersion,
|
|
1319
1435
|
_isPlaceholderSecret, _looksSecretLike,
|
|
1320
1436
|
_mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue,
|
|
1321
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.
|
|
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",
|