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/CHANGELOG.md +134 -0
- package/README.md +4 -4
- package/bin/leerness.js +531 -27
- package/lib/graph.js +287 -5
- package/lib/pure-utils.js +112 -2
- package/package.json +1 -1
- package/scripts/e2e.js +509 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,139 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.97 — 2026-08-04 — 가장 많이 물리는 축을 가장 얕게 묻고 있었다 (P-0011)
|
|
4
|
+
|
|
5
|
+
사용자 질문: *"leerness가 설치된 프로젝트에서 작업할 때 이 개념들(11개 공통 축 · 20개 분야)이 적용되는지 확인하고, 적용할 가치가 있는지 판단해줘."*
|
|
6
|
+
|
|
7
|
+
**측정부터 했다.** 렌즈는 7개 도메인이 있었지만 깊이가 극단적으로 치우쳐 있었다 — `database` 12문항 7,193자, `debug` 6문항 538자, 나머지 5종은 각 3문항 125~168자. 11축 중 4축(소유권·생명주기·성능확장성·관측가능성)은 아예 없었고, 사용자가 준 15개 설계 질문 중 실제로 묻는 건 4~5개였다.
|
|
8
|
+
|
|
9
|
+
결정적인 건 비율이 아니라 **어디서 물렸는가**였다. 직전 라운드(1.36.96)에서 외부 검수 8회가 막은 57건은 계약(378바이트 스텁) · 실패복구(없음) · 신뢰보안(3문항) 축에 몰렸고, **12문항짜리 동시성 축에서 나온 건 0건**이었다. 가장 깊은 렌즈가 실제 작업과 하나도 안 맞았다.
|
|
10
|
+
|
|
11
|
+
그래서 축 11개를 나열하지 않았다 — 이름만 늘리면 매 세션 읽히는 배경음이 되고, 이 저장소에서 낡은 경고가 무시되는 걸 이미 봤다. `database` 렌즈가 **작동한 이유**만 복제한다: (a) 이름이 아니라 구체적 반례, (b) 파일 내용 기반 자동 소환.
|
|
12
|
+
|
|
13
|
+
- **계약 · 실패·복구 · 관측 가능성** 3축을 각 12문항으로 신설(KO/EN 동수). 반례는 되도록 이 저장소에서 실제로 물린 사건으로 채웠다 — 상한을 UTF-16 코드 단위로 세어 한글 문서에서 512KB 주장이 실제 1,152KB 였던 일(계약: 단위를 안 적은 대가), 깊이 상한 너머를 원본 그대로 통과시켜 상한이 곧 우회 경로가 된 일(복구: 문제는 상한이 아니라 실패 방향), stderr 전문을 끝까지 읽고서야 "환경 탓"이 내 문법 오류였음을 안 일(관측: 첫 줄만 남기면 그 진단이 불가능하다).
|
|
14
|
+
- **`axes` 8축 경량 점검** — 나머지 축은 한 축당 한 문항. 이미 깊은 렌즈가 있는 축(순서·동시성 → database, 신뢰·보안 → security, 인간 요소 → design/docs)은 중복 서술 대신 그 렌즈로 넘긴다(같은 내용을 두 곳에 적으면 한쪽이 반드시 낡는다).
|
|
15
|
+
- **소환은 내용 기반** — 경로에 단서가 없는 파일도 켜진다. 신호는 낱말이 아니라 **"코드 모양으로 쓰였는가"** 다: 빈 catch · `.catch(() => {})` · `backoff:`/`maxRetries =` 같은 대입·옵션 문맥 · `new AbortController` · SIGTERM 핸들러(복구), `logger.error` · catch **블록** 안의 `console.error` · `traceId`/`x-request-id` · `metrics.inc(` · `@opentelemetry/`(관측), 라우트 정의 · `res.status(4xx)` · zod/Joi 스키마 · `@deprecated` · protobuf IDL(계약).
|
|
16
|
+
- **경량 축은 신호가 있을 때만 한 줄** 붙는다(반복문 안 `await` → 성능·확장성, `setInterval`/`addEventListener` → 생명주기). 신호가 없으면 아무것도 붙이지 않는다.
|
|
17
|
+
- **도메인 목록을 세 표면(help ko · help en · commands)에 손으로 적던 것을 카탈로그에서 파생**시켰다. 축을 추가할 때마다 세 곳을 동시에 고쳐야 했고, 한 곳만 놓치면 낡은 목록이 계속 광고된다.
|
|
18
|
+
|
|
19
|
+
**출하 직전 검수가 이 변경의 P1 두 건을 잡았다 — 둘 다 내가 만든 것이다.**
|
|
20
|
+
|
|
21
|
+
① **정규식 2차 폭발.** 줄머리 앵커 뒤에 쓴 `\s*` 가 개행까지 먹어, 빈 줄이 이어지면 O(n²) 가 됐다. 512KB 빈 줄 파일에 **227,760ms(3분 48초)**, 실제 CLI 로도 150KB 파일에 13.8초(대조군 1.2초). 상한 512KB 는 비용 가드였는데, 실제로는 `512KB × 512KB` 를 허용하고 있었다. 들여쓰기는 공백·탭이지 개행이 아니다 — `[ \t]*` 로 조이자 **227,760ms → 2.7ms**, 진짜 양성 인식은 5/5 그대로다. 내 사전 프로브가 이걸 놓친 이유는 분명하다: **정규식의 구조가 아니라 내가 상상한 공격 모양에서 입력을 만들었다**(`catch` 폭탄·CRLF 는 시험했고 순수 개행 연속은 안 했다).
|
|
22
|
+
|
|
23
|
+
② **낱말 신호가 산문·UI 문구에서 켜졌다.** i18n 카탈로그 한 줄(`'error.backoff': 'Too many attempts…'`)이 recovery+observability 를 띄우고 `code` 렌즈를 통째로 밀어냈다. 기능 탐지 배열의 `'AbortController'`, 개수 세는 `counter.set(`, 레이 트레이서의 `tracer`, `faker.company.catchPhrase()` 도 마찬가지였다. 반대로 `@opentelemetry`·`@sentry` 대안은 `\b` 를 `@` 앞에 둬서 **어떤 실제 코드에서도 매칭될 수 없었다**(죽은 대안을 넣고 커버리지가 있다고 믿고 있었다). 전부 대입·호출·블록 문맥 요구로 바꿨다.
|
|
24
|
+
|
|
25
|
+
**캡 설계도 틀렸다.** 구체 축을 앞세우고 전체를 2로 자르니 실제 커밋 226개 중 141개(62%)에서 출력이 바뀌고 일반 렌즈가 보이는 커밋이 99% → 57% 로 떨어졌다 — 구현+테스트 커밋이 `test` 질문을, code+README 커밋이 `docs` 질문을 아예 못 받았다. **새 축은 더하는 것이지 없애는 것이 아니다**: 일반 축 상한(2)을 1.36.96 그대로 두고 구체 축 2개를 그 위에 얹는다. 실측으로 **실제 커밋 188개에서 렌즈 제거 0건**(추가만 102건). 줄이 늘 것 같지만 반대다 — 인라인은 목차이지 본문이므로 질문의 첫 절만 찍게 바꿔 **advisory 블록이 15행 → 6행(ko) / 8행(en)** 이 됐다. 축은 2개에서 4개로 늘었는데 출력은 짧아졌다.
|
|
26
|
+
|
|
27
|
+
**렌즈 본문의 사실 오류 3건도 고쳤다.** ⓐ "Promise.all 은 처리되지 않은 거부를 남긴다"는 **틀렸다** — all 은 모든 프라미스를 구독하므로 늦은 거부도 처리된다(직접 실행해 확인: all 0건, 개별 await 방치 1건). 진짜 위험은 "취소되지 않고 계속 실행되며 결과가 버려진다"는 쪽이라 그렇게 다시 썼다. ⓑ 4xx 를 재시도 불가로 뭉뚱그렸는데 408·425·429 는 재시도 대상이다. ⓒ `contract verify` 가 명세↔구현 대조를 기계화한다고 적었지만 그 명령은 이름·필드 **존재**만 본다 — 과장을 걷어냈다(이건 1.36.5 에서 내가 고쳤던 바로 그 과장이다).
|
|
28
|
+
|
|
29
|
+
**측정 자체가 틀렸던 것도 바로잡는다.** 처음 적은 "recovery 1.4% · 어느 축이든 9.2%" 는 분모에 `.venv`·`site-packages`·생성 번들이 섞여 서드파티 코드로 희석된 값이었다. 손으로 쓴 소스만(벤더·빌드 산출물·미니파이·기계생성 제외) 다시 재면 **19개 프로젝트 3,463 파일에서 recovery 중앙값 6.5% · observability 8.3% · contract 4.0% · database 1.9%, 어느 축이든 31.3%** 다. 7배 낮게 적었던 셈이다. 다만 캡을 고친 지금은 이 비율이 '밀어냄' 이 아니라 '한 줄 더함' 이라 위험도가 다르다.
|
|
30
|
+
|
|
31
|
+
**신설한 렌즈가 자기 자신에게 먼저 물렸다.** 패키지의 테스트 제외 33개 파일 중 18개에 **주석 없는 `catch {}`** 가 있고 총 911회 나온다(`bin/leerness.js` 427 · `scripts/e2e.js` 378 · `lib/` 28개 중 16개 파일 106회). 상당수는 의도한 방어적 무시겠지만, recovery 5번 문항이 요구하는 "왜 안전한지 한 줄"이 없어 **의도와 사고를 코드만 보고 구분할 수 없다** — 별건(T-0086)으로 스윕한다.
|
|
32
|
+
|
|
33
|
+
**남의 커스텀 렌즈를 덮지 않는다.** 내장 도메인을 새로 만들면 그 이름을 먼저 쓰고 있던 프로젝트(`.harness/quality-lenses.json`)와 충돌한다. 실측해 보니 질문은 보존되는데 **사용자가 적어 둔 제목이 조용히 사라졌다**('우리팀 계약' → '계약(경계의 약속)') — 내용은 그대로인데 이름만 바뀌니 오히려 더 헷갈린다. 프로젝트가 명시한 title/persona 를 살리되 로케일별로 따로 받는다(ko 제목만 준 프로젝트의 `--language en` 출력에 한글이 새면 안 된다). 덤으로, 절단 경고가 하드코딩하던 "12+커스텀 상한"도 도메인별 실제 내장 문항 수로 파생시켰다(`axes` 는 8 이라 그 문구가 이미 거짓이었다).
|
|
34
|
+
|
|
35
|
+
**도그푸드가 잡은 것 하나 더.** `verify-claim` 을 이 변경 자체에 걸었더니 recovery 가 뜨지 않았다 — `bin/leerness.js`(1,799KB)와 `scripts/e2e.js`(692KB) 가 512KB 상한에 걸려 **이 저장소의 주력 파일 두 개가 영구히 스캔 밖**이었다. 파일당 4MB 로 올리되 호출당 총 32MB 예산을 함께 뒀다(비용은 파일 하나가 아니라 호출 하나에 묶어야 한다). 그리고 축마다 파일을 다시 열던 것을 **1회 읽기**로 바꿨다 — 같은 파일을 여섯 번 읽으면 IO 도 폭발 위험도 여섯 배다. 검수는 **디렉토리 정션으로 root 밖 파일이 읽히는 것**도 재현했다(문자열 접두만 봤고 최종 파일만 `lstat` 했다). 양쪽을 `realpath` 로 해석해 다시 봉쇄했다 — 1.36.4 부터 있던 구멍이다.
|
|
36
|
+
|
|
37
|
+
**가드.** selftest 2건 신설(343건). 양성만 재면 항상-참 술어도 통과하므로 **대조군을 함께** 넣었고, 검수가 재현한 오탐(i18n 문구·폴리필 배열·개수 세는 Map·레이 트레이서·`catchPhrase`)과 누락(`@opentelemetry` import)을 그대로 케이스로 박았다. **2차 폭발 회귀 가드**도 넣었다 — 256KB 빈 줄 입력에 시간 예산을 걸어, `\s*` 로 되돌리면 실패한다.
|
|
38
|
+
|
|
39
|
+
가드가 처음부터 튼튼했던 적은 없다. 변이 1차에서 5개가 살아남았는데 원인이 각각 달랐다: 3개는 **내 변이가 no-op**(문두만 바꿔 판별 문구가 그대로), 1개는 **픽스처가 크기·심볼릭 분기를 지나지 않음**, 1개는 **진짜 구멍 — 영문 표면에 단언이 없어 EN 문항을 한글로 바꿔도 통과**(1.36.89 에서 이미 겪은 구멍이라 `questionsEn` 에 한글 0 을 못박았다). e2e 절제에서도 1개가 살아남았는데 하필 **파생 목록의 존재 이유인 help/commands 세 표면을 아무도 안 보고 있었다**. 심볼릭 링크 단언은 Windows 에서 권한이 없어 조용히 건너뛰어 **주 플랫폼에서 죽어 있었다** — 권한 없이 만들 수 있는 **디렉토리 정션**으로 바꿔 되살렸고, 링크 갈래가 하나도 시험되지 않으면 실패하도록 했다(건너뜀을 통과로 세지 않는다).
|
|
40
|
+
|
|
41
|
+
## 1.36.96 — 2026-07-31 — 실행기를 만들지 않고 문서를 띄운다 (P-0010)
|
|
42
|
+
|
|
43
|
+
사용자 요청: *"leerness.html 대신 exe 같은 실행기로 로드맵·그래프·스킬·기술·핸드오프·플랜 마크다운을 그대로 띄울 수 있나? 성능에 영향을 준다면 leerness.html 로 유지하고 거기에 반영."*
|
|
44
|
+
|
|
45
|
+
**실행기는 만들지 않았다.** exe/Electron 은 런타임 의존성 0 · install script 0 · 사용자 저장소에 실행기 파일 미생성이라는 경계를 깬다 — 그 경계는 1.36.90 에서 테스트로 못박아 뒀다. 사용자가 준 조건("성능에 영향을 준다면 leerness.html 에 반영")이 그대로 답이었다. 같은 목적을 **정적 단일 파일 안에서** 이룬다.
|
|
46
|
+
|
|
47
|
+
**📄 문서 탭** — `.harness` 핵심 문서 7종(플랜·스킬·기술 프로필·세션 핸드오프·현재 상태·진행 추적·결정 기록)을 `leerness.html` 안에서 그대로 읽는다. 좌측 목록 → 우측 본문, 브라우저만 있으면 된다.
|
|
48
|
+
|
|
49
|
+
- **렌더러는 최소 부분집합만 해석한다** — 제목(H1~H6) · **단층** 목록(중첩은 평탄화) · 순서 목록(**원문 번호 보존** — 시작값과 건너뛴 번호를 `ol start`·`li value` 로) · 코드펜스 · 표(**정렬 반영**) · 링크 · 강조. 나머지는 **이스케이프된 평문**이다(예외: 자리표시자와 충돌하는 U+0001·U+0002 는 표시 전에 제거한다). 이 파일은 사용자 문서를 그대로 싣기 때문에 파서를 넓힐수록 XSS 면이 넓어진다. 원시 HTML(`<script>`, `<img onerror>`)은 글자로 남고, 링크는 `http(s)`/`mailto`/앵커/상대경로만 통과한다(`javascript:`·`data:`·`vbscript:`·프로토콜 상대 `//` 거부).
|
|
50
|
+
- **자격증명 표지가 있는 문서는 본문을 아예 싣지 않는다.** 값 단위 마스킹을 다섯 번 고쳤고 매번 새 누출 경로가 나왔다(아래) — 그래서 **마스킹을 안전의 전제에서 뺐다**. 표지는 값이 아니라 **형식**으로만 정의해 줄 앞 접두어(`> `·`- `·들여쓰기)·대소문자·base64 조성·placeholder 모양에 의존하지 않는다. 마스킹(`redactSecrets`, 대시보드와 같은 술어)은 표지가 없는 문서에 대한 **두 번째 방어층**으로만 남는다.
|
|
51
|
+
- 비용을 먼저 쟀다 — 실제 프로젝트 문서 **156개 중 표지가 걸린 파일 1개(0.6%)**. 사실상 공짜라서 채택했다.
|
|
52
|
+
- 검증: 알려진 경로 **14종**(원본/인용문/목록/들여쓰기/CRLF/END없음 PEM 6 · `~`·`+`·`/` Bearer 3 · 소문자 `bearer` 1 · `AUTH=` 뒤 토큰 1 · placeholder 모양 1 · 반복 문자 1 · DB 접속문자열 1) 전부 본문 미포함. 이후 검수가 보인 **네 형태**(RFC6750 짧은 Bearer · 쿠키 · compact JWE · 강조로 감싼 스킴)도 같은 술어에서 검출된다. 표지 없는 문서는 그대로 실린다.
|
|
53
|
+
- **상한과 고지**: 문서당 64KB · 전체 512KB(**UTF-8 바이트**). 잘리면 `x/y자(뒷부분만)` 로 적는다. 전체 상한이 남은 경우 **잔여 예산만큼 담고**, 잔여가 의미 없는 크기(전체 상한의 1/8, 최대 2KB)면 조각 대신 "담지 않았다"고 고지한다. (기본 7종에서는 7×64KB=448KB 라 전체 상한이 걸리지 않는다 — 목록이 늘 때를 위한 backstop 이고, 상한을 주입해 분기를 실제로 발화시켜 시험한다.) 누적 로그(진행 추적·결정 기록)는 **뒤**를 남기고 그 외는 앞을 남긴다 — 1.36.95 handoff 에서 고친 그 방향 규칙을 그대로 따른다.
|
|
54
|
+
- 실측(이 저장소): 문서 탭이 `leerness.html` 에 더하는 양은 **+39.2KB**(120.8KB → 159.9KB). 자동 생성 기본값은 바꾸지 않았다.
|
|
55
|
+
|
|
56
|
+
**만들면서 세 번 틀렸고, 셋 다 다른 방식으로 드러났다.**
|
|
57
|
+
|
|
58
|
+
1. **코드스팬 자리표시자가 본문의 모든 숫자를 삼켰다.** 자리표시자를 감싸는 문자를 빠뜨려 복원 정규식이 `/(\d+)/g` 가 됐고, `1.36.96` 같은 평범한 숫자가 코드로 바뀌었다. 게다가 그 문자를 **원시 제어문자(U+0001)로** 소스에 심어 놓아 눈에 보이지도 않았다 — 명시적 ``\u0001`` 이스케이프로 바꿨다.
|
|
59
|
+
2. **전체 상한 512KB 가 구조적으로 도달 불가능했다.** 7종 × 64KB = 448KB 라 `skipped` 분기가 한 번도 실행되지 않았고, 그걸 검사하던 `every` 는 빈 컬렉션 위에서 공허하게 참이었다. 상한값을 주입 가능하게 해 분기를 실제로 발화시켰다(측정: skipped 3건). 그 과정에서 **159자짜리 쓸모없는 조각**이 "잘림"으로 표시되던 것도 발견해, 잔여 예산이 의미 없으면 조각 대신 "담지 않았다"로 바꿨다.
|
|
60
|
+
3. **좁은 화면에서 레이아웃이 무너졌다** — 190px 고정 내비가 자리를 다 먹어 본문이 한 글자씩 세로로 흘렀다. 테스트로는 보이지 않는 종류라 **실제 브라우저로 열어 봐야** 잡혔다. 760px 이하에서 내비를 가로 칩으로 접는다(실측: 375px 에서 본문 폭 90 → 323px, 가로 스크롤 0).
|
|
61
|
+
|
|
62
|
+
**그리고 검수가 출하를 막았다 — 10건 중 7건이 재현됐고 전부 고쳤다.**
|
|
63
|
+
|
|
64
|
+
- **P1 시크릿 누출 — 마스킹이 스캐너보다 약한 목록을 쓰고 있었다.** DB 접속문자열 비밀번호 · GitLab PAT · npm 토큰 · Stripe live 키 · SendGrid · 개인키가 **그대로 화면에 실렸다**(실측 6종). 전부 `catalogs.js` 의 `SECRET_PATTERNS` 에 **이미 있던** 패턴이다 — 한쪽 표면만 강화된 상태로 방치돼 있었다. 같은 목록을 공유하게 하고(placeholder·엔트로피 가드도 스캐너와 동일), 개인키는 BEGIN 줄만이 아니라 **블록 전체**를 지운다(종전엔 본문 base64 가 남았다). `leerness.html` 은 공유·첨부되는 파일이라 결과가 더 나빴다. 이 수정은 대시보드에도 함께 적용된다.
|
|
65
|
+
- **P2 빈 경계 줄 하나가 문서 전체를 지웠다.** 선두 빈 줄 + 초장문 한 줄이면 `keep` 은 1 이 되는데 담긴 내용은 0자였다 — **70,000자 문서가 빈 화면**이 됐다(누적 로그는 끝 개행 하나로 같은 일이 났다). 줄 수가 아니라 담긴 **내용**으로 판정하고, 없으면 경계의 내용 있는 줄을 코드포인트로 자른다.
|
|
66
|
+
- **P2 "64KB/512KB" 가 사실이 아니었다.** UTF-16 코드 단위를 세면서 KB 라고 적어, 한글 문서에서 합계 **1,152KB**(HTML 1.21MB)까지 실렸다. 상한을 UTF-8 바이트로 바꿔 주장을 참으로 만들었다.
|
|
67
|
+
- **P2 링크 정규식이 O(n²) 였다.** 닫히지 않은 대괄호마다 남은 문자열 전체를 다시 훑어 64KB 입력에서 **1,825ms**(최악 2,730ms) 브라우저 메인 스레드가 멈췄다. 수량자를 유계로 바꿔 **41ms**(최악 23ms).
|
|
68
|
+
- **P2 역슬래시 프로토콜 상대 URL 이 통과했다.** 슬래시 2개만 막았는데 브라우저는 역슬래시를 슬래시처럼 읽는다 — `\\host\share` 가 링크가 됐다.
|
|
69
|
+
- **P2 e2e 가 계약을 깨는 변이 3종을 통과시켰다.** 허용 링크는 HTTPS 하나만, 제목은 H2 만 검사했고, `tech`·`progress` 두 문서는 아무것도 확인하지 않아 경로가 끊겨도 초록이었다.
|
|
70
|
+
- **P3 링크를 만든 뒤 강조 정규식이 href 까지 다시 해석했다** — `href="https://a/<b>b</b>/c"`. 완성된 앵커를 자리표시자로 빼 둔다. 문서에 실제로 U+0001 이 있으면 자리표시자와 충돌하던 것도 함께 막았다.
|
|
71
|
+
- **검수가 틀린 것도 있었다.** SendGrid "누출"은 세 번 연속 **내 픽스처**가 틀린 것이었고(뒤가 38자 → 같은 글자 반복 → 39자) 코드는 처음부터 정상이었다. 길이를 손으로 세지 말고 생성하도록 바꿨다. 마스킹 오탐 1건(`password = 사용자가…`)도 1.36.95 규칙만으로 재현해 **회귀가 아님**을 확인했다.
|
|
72
|
+
|
|
73
|
+
**그리고 재검수가 다시 막았다 — 6건 중 3건이 위 수정이 만든 새 결함이었다.**
|
|
74
|
+
|
|
75
|
+
- **P1 END 줄이 없는 개인키는 본문이 그대로 남았다.** "블록 전체를 지운다"는 규칙이 `-----END …-----` 를 요구했는데, 붙여넣다 잘린 키가 오히려 흔하다. BEGIN 뒤에 이어지는 base64/헤더 줄까지 함께 지운다.
|
|
76
|
+
- **P2 빈 경계 줄이 두 개면 같은 데이터 소실이 재발했다.** 내가 "담긴 내용으로 판정"한다며 쓴 `acc` 에는 **줄바꿈 바이트가 포함**돼 있었다 — 빈 줄 두 개면 `acc=1` 이 되어 판정을 통과하고 70,000자가 줄바꿈 한 글자로 줄었다. 내용 바이트를 따로 센다. 픽스처를 빈 줄 **한 개**로만 만든 탓에 못 봤다.
|
|
77
|
+
- **P2 스캐너 목록을 그대로 공유한 것이 정상 산문을 파괴했다.** 그 목록에는 `AWS secret …` · `Bearer …` 같은 **탐지 전용 문맥 휴리스틱**이 섞여 있다. 탐지에서 오탐은 경고 한 줄이지만, **가림에서 오탐은 사용자가 보는 문서를 조용히 훼손**한다(대시보드도 같은 함수를 쓴다). 실측: `결정: AWS secret commit <해시> was reverted` → `결정: *** was reverted`. 엔트로피 게이트로는 갈리지 않았다(진짜 AWS 키까지 placeholder 로 판정된다). 문맥 패턴은 가림에서 제외하고, Bearer 는 값에 숫자가 있을 때만 가린다. 남는 구멍(`Authorization:` 없이 적힌 영문자만의 Bearer 토큰)은 명시해 둔다.
|
|
78
|
+
- P3 링크 길이 상한이 **이스케이프 후** 길이에 걸려 원문 796자짜리 정상 쿼리 URL 이 거부됐다(`&`→`&` 팽창). 실측 비교로 라벨 300 / URL 4,000 을 골랐다 — 최악 115ms(무계는 1,747ms).
|
|
79
|
+
- P3 잘림 고지의 "자" 가 UTF-16 코드 단위였다 — 이모지 문서에서 `32,767/60,001자` 로 표시되지만 실제는 `16,384/30,001자`. 코드포인트로 센다.
|
|
80
|
+
- **내 실측이 "신규 오탐 0" 이라고 말했는데도 오탐이 실재했다.** 실제 문서 189개에 그런 문장이 없었을 뿐이다 — **실제 데이터 측정은 구성된 반례를 대체하지 못한다.**
|
|
81
|
+
|
|
82
|
+
**세 번째 검수에서 7건이 더 나왔고, 같은 자리에서 세 번 틀린 것이 있었다.**
|
|
83
|
+
|
|
84
|
+
- **P1 암호화 개인키의 본문이 샜다.** 암호화 키는 `Proc-Type:`/`DEK-Info:` 헤더 뒤 **빈 줄**이 오고 본문이 시작하는데, 규칙이 빈 줄에서 멈췄다. 반대로 아무 `Name: value` 줄이나 먹으면 BEGIN 을 인용한 정책 문서의 산문을 삼킨다 — 헤더는 **PEM 표준 이름만** 허용하고 본문 base64 줄이 최소 하나 있을 때만 확장한다.
|
|
85
|
+
- **P1 무관한 산문을 통째로 삼키는 경로가 있었다(변이 실험 중 발견).** `[\s\S]*?` 로 BEGIN 과 END 를 잇는 규칙은 그 사이의 **아무 내용이나** 먹는다 — 종료되지 않은 키 하나가 문서 뒤쪽의 END 줄과 짝지어지면 사이의 모든 문단이 사라진다. 사이에는 PEM 헤더·base64·빈 줄만 허용하도록 좁혔다.
|
|
86
|
+
- **P2 영문자만으로 된 32자 Bearer 토큰과, 구분자 없는 AWS 실값이 샜다.** 앞 라운드에서 오탐을 막으려고 건 조건이 실값까지 놓쳤다. Bearer 는 "숫자가 있거나 32자 이상", AWS 문맥은 패턴을 되살리되 **꼬리 값만** 치환한다(문장 구조는 남는다 — 커밋 해시가 함께 가려지는 것은 감수한다. 누출보다 낫다).
|
|
87
|
+
- **P2 데이터 소실이 세 번째로 재발했다 — CRLF 의 `\r`, 공백, 탭이 "내용"으로 세어졌다.** `keep > 0` → `acc > 0` → `content > 0` 으로 세 번 고쳤고 매번 셈을 조금씩 틀렸다. **같은 자리에서 세 번 틀렸다면 픽스처가 아니라 접근이 틀린 것이다.** 셈을 없앴다 — 줄 단위로 담은 것과 경계 줄을 코드포인트로 자른 것을 **둘 다 만들어 긴 쪽**을 쓴다. 어떤 공백 조합에도 예산을 채우는 쪽이 이긴다.
|
|
88
|
+
- **P2 URL 스킴 판정이 C0 제어문자로 우회됐다.** 탭·개행만 지웠는데 `\x03`·`\x01`·`\x00`·`\x1F`·`\x7F` 를 앞이나 중간에 끼우면 통과했다. C0 전체와 DEL 을 제거한다.
|
|
89
|
+
**네 번째 검수에서 또 7건 — 그리고 "A를 고치면 B가 깨지는" 형태가 두 번 나왔다.**
|
|
90
|
+
|
|
91
|
+
- **P1 20~31자 영문 Bearer 토큰이 샜다.** 앞 라운드에서 오탐을 막으려 건 길이 문턱(32자)이 실제 토큰을 놓쳤다 — OAuth 토큰 길이는 보장되지 않는다. **길이가 아니라 모양**으로 가른다: 낱말을 이어 붙인 camelCase(각 조각 3자 이상)만 산문으로 보고 나머지는 가린다. `authenticationMiddleware` 는 조각이 14/10자라 산문, `AbCdEfGhIj…` 는 조각이 2자라 토큰이다.
|
|
92
|
+
- **P2 절단이 문서의 제목·푸터를 버렸다.** "줄 단위 결과와 코드포인트 절단 결과 중 긴 쪽" 이라는 앞 라운드의 수정이, 경계 줄이 길면 **앞부분을 통째로 버렸다** — `TITLE` + 초장문이면 `TITLE` 이 사라지고 화면 고지의 "앞부분만" 이 거짓이 된다. 온전한 줄로 채운 뒤 **남은 예산만큼 경계 줄을 이어 붙인다**. 그런데 그렇게만 하면 이번엔 평범한 문서까지 줄 중간에서 잘렸다 — **혼자서는 어떤 예산으로도 담길 수 없는 줄**일 때만 이어 붙이도록 좁혀 두 성질을 함께 만족시켰다.
|
|
93
|
+
- **P2 BEGIN 인용 뒤의 평범한 영문 한 줄을 개인키 본문으로 오인해 지웠다.** 본문 요건이 16자였다. 40자로 올렸더니 이번엔 **줄이 짧게 감긴 키의 본문이 샜다**(내 회귀 확인에서 22자·16자 본문이 남았다). **길이가 아니라 내용**으로 가른다 — 진짜 base64 는 숫자나 `+ / =` 를 포함하고, 영문 낱말을 이어 붙인 식별자는 포함하지 않는다.
|
|
94
|
+
- P3 PGP 블록은 END 앞 `=CRC` 줄을 허용하지 않아 CRC 와 END 가 남았다.
|
|
95
|
+
- **두 번 다 "더 센 조건" 이 아니라 판별 기준 자체를 바꾸는 것이 답이었다.** 길이 문턱을 올렸다 내렸다 하면 한쪽이 반드시 새거나 파괴된다.
|
|
96
|
+
|
|
97
|
+
**다섯 번째 검수가 그 "판별 기준"마저 양방향으로 깼다 — 그래서 원칙을 정했다.**
|
|
98
|
+
|
|
99
|
+
- **P1 실제 RSA 키의 영문자 전용 본문 줄에서 51줄이 그대로 노출됐다.** 검수가 진짜 키를 생성해 16자로 감싸니 영문자만인 줄이 나왔고, "진짜 base64 는 숫자나 `+ / =` 를 포함한다"는 내 판별이 무너졌다. 반대 방향도 함께 무너졌다 — `PolicyVersion2026Final` 은 숫자를 포함한 22자 영숫자라 본문으로 분류돼 문서가 훼손됐다.
|
|
100
|
+
- **P1 `Bearer abcdefghijklmnopqrst` · `Bearer CorrectHorseBatteryStaple` 같은 유효한 불투명 토큰이 산문으로 판정돼 노출됐다.** 낱말꼴 예외를 둔 순간 **그 예외가 곧 누출 경로**가 됐다.
|
|
101
|
+
- **다섯 번 시도해 매번 한쪽이 깨졌다면, 그건 판별의 문제가 아니라 원칙의 문제다.** 값의 조성으로 시크릿과 산문을 가르려는 시도에는 반드시 반례가 있다. 그래서 원칙을 정했다 — **표시용 마스킹은 안전한 쪽으로 실패한다.** 값이 아니라 **문맥**으로 판정한다: `BEGIN` 마커 뒤의 **줄 전체에 공백이 없는 줄**은 키 재료로 본다. 산문은 거의 항상 공백을 포함하므로 문단은 살아남고, 어떤 조성의 키 본문도 새지 않는다. Bearer 도 예외를 없앴다.
|
|
102
|
+
- **대가는 명시한다.** BEGIN 을 인용한 문서에서 **바로 뒤의 단일 토큰 한 줄**이 가려지고, `Use Bearer authenticationMiddleware for API requests.` 는 `Use *** for API requests.` 가 된다. 원본 파일은 그대로이고 이건 표시용 스냅샷이므로, 누출보다 이쪽이 낫다. (20자 미만은 카탈로그가 후보로 잡지 않으므로 `Bearer 인증` 같은 표현은 영향이 없다.)
|
|
103
|
+
- 검증: 진짜 RSA 키를 생성해 16자·64자 두 방식으로 감싸고 END 유무까지 조합해 **본문 0줄 누출**을 확인했다.
|
|
104
|
+
|
|
105
|
+
**여섯 번째 검수가 그 원칙마저 뚫었다 — 그래서 마스킹을 안전의 전제에서 뺐다.**
|
|
106
|
+
|
|
107
|
+
- **P1 Markdown 인용문(`> `)으로 감싼 실제 개인키가 전량 노출됐다.** 내 규칙은 줄 앞에 공백·탭만 허용했다. README·정책 문서에서 키를 blockquote 로 인용하는 것은 자연스러운 마크다운이다. 16자 래핑에서 51/51, 64자에서 13/13 줄이 그대로 실렸다.
|
|
108
|
+
- **P1 Bearer "예외 없음" 원칙에도 누출 경로가 넷 남아 있었다** — 카탈로그가 대문자 `Bearer` 만 받고 `~`·`+`·`/` 를 문자군에서 빠뜨렸으며, `AUTH=Bearer <토큰>` 은 앞부분만 가려 `AUTH=*** <실토큰>` 이라는 **거짓 안심**을 만들었고, placeholder 모양 토큰은 가드가 통과시켰다.
|
|
109
|
+
- **여섯 번을 고쳤는데도 새 경로가 나온다면, 고치는 방향이 틀린 것이다.** 값 단위 마스킹이 완벽해야 안전한 구조를 버렸다 — **자격증명 표지가 하나라도 있으면 그 문서의 본문을 싣지 않는다.** 이제 마스킹이 부분적으로 실패해도 누출로 이어지지 않는다.
|
|
110
|
+
- **변이가 5건 살아남아 픽스처의 결함도 드러났다** — 시크릿을 한 파일에 몰아넣으면 **표지끼리 서로를 가려 줘서**, 특정 표지를 무력화하는 변이가 그대로 통과했다. 표지 술어는 **하나씩 격리**해 단언한다(7/7 검출). 남은 생존 2건은 `_SECRET_BEARER` 가 먼저 가려 등가가 된 것이라 목록에서 뺐고, 마스킹 제거 변이는 "가드를 통과한 문서에도 두 번째 층이 살아 있는가" 단언을 넣어 잡았다.
|
|
111
|
+
|
|
112
|
+
**일곱 번째 검수가 가장 아픈 것을 짚었다 — 내가 표면 하나만 보고 안전을 선언했다.**
|
|
113
|
+
|
|
114
|
+
- **P1 문서 탭을 가드해도 같은 내용이 `nodes`·`roadmap` 으로 그대로 나갔다.** `decision add "Authorization: Bearer <토큰>"` 이 `DATA.nodes[].label` 과 `detail.decision` 에 원문으로 실렸다(실측). 그래프 쪽에는 **마스킹조차 없었다.** "문서 본문을 통째로 제외하므로 마스킹 실패가 누출로 이어지지 않는다"는 내 전제가 성립하지 않았다.
|
|
115
|
+
- 필드마다 손으로 감싸면 반드시 빠뜨린다(그래서 문서 탭만 덮였다). **임베드 직전 한 곳**에서 모든 문자열을 같은 술어로 통과시킨다 — 지금 있는 표면도, 나중에 추가될 표면도 함께 덮인다.
|
|
116
|
+
- 실측: decision·task·plan 세 경로 모두 생성 HTML 과 `DATA` 전 경로에서 0건. 정상 항목은 그래프에 그대로 남는다.
|
|
117
|
+
- **P1 표지를 빠져나가는 네 형태** — RFC6750 의 짧은(19자) Bearer, 세션/Set-Cookie, compact JWE(5분절이라 `eyJ…eyJ` 가 아니다), 마크다운 강조로 감싼 `` `Bearer` ``. 넓히기 전에 비용을 쟀다: 실제 문서 156개에서 **추가로 걸리는 파일 0개**라 셋 다 넓혔다.
|
|
118
|
+
**여덟 번째 검수에서 P1 이 처음으로 0 이 됐고, 남은 P2 는 내가 방금 넣은 코드가 원인이었다.**
|
|
119
|
+
|
|
120
|
+
- **깊이 처리를 세 번 바꿨고, 두 번째 진단이 틀렸다.** ① 상한 12 + **원본 통과** → 12단계 밖이 정규화를 우회(누출). ② "상한이 우회를 만든다"고 보고 **상한을 제거** → 약 24KB 짜리 유효 JSON 하나로 **생성 자체가 실패**(직렬화가 4천 단계에서 스택을 터뜨린다). ③ 상한 200 + **자리표시자 대체** → 8천 단계까지 생성 성공, 누출 0. **문제는 상한이 아니라 상한을 넘겼을 때 원본을 통과시킨 것이었다 — fail-open 이면 우회, fail-safe 면 보호다.**
|
|
121
|
+
- **순환을 막으려 쓴 방문 집합이 정상 데이터를 지웠다.** 전역 집합으로 두면 같은 객체를 두 필드가 공유할 때 두 번째가 순환이 아닌데도 `null` 이 된다(실측). 재귀 스택처럼 **들어갈 때 넣고 나올 때 빼야** 진짜 순환만 끊긴다.
|
|
122
|
+
- **CRLF 문서를 앞에서 자를 때 고립된 `\r` 이 남았다** — `\n` 으로만 나누므로 줄 끝의 `\r` 이 내용으로 세어지고, 재조립 때 개행이 붙지 않으면 제어문자가 표시 데이터에 남는다.
|
|
123
|
+
- **순서 목록 번호가 조용히 바뀌었다** — `2. two` 가 브라우저에서 `1. two` 로, `1·3` 이 `1·2` 로 보였다. 문서의 의미를 바꾸는 것이라 원문 번호를 보존한다. **표 정렬**도 문법으로는 받으면서 의미를 버리고 있었다 — 받은 이상 반영한다.
|
|
124
|
+
- **변이 진단에서 픽스처가 판별점을 비껴간 경우가 셋 더** — CRLF 는 경계 줄이 이어붙어 정상 CRLF 가 되고(상한 안에 들어가는 줄로 교체), 순환은 깊이 상한이 먼저 끊어 주며(출력 크기까지 단언), 깊이 상한은 "생성 성공"만 보면 낮춰도 통과한다(보통 깊이 데이터 보존까지 단언).
|
|
125
|
+
|
|
126
|
+
- **변이 진단에서 픽스처 함정이 둘 더 나왔다** — 누출 needle 을 `Authorization: Bearer …` 로 만들었더니 **마스킹만으로도 지워져** 표지 가드 제거 변이가 살아남았고(쿠키 형태로 교체), Bearer 문자군 판별 픽스처는 `/`·`~` 를 맨 앞에 둬서 **접두 허용이 흡수**해 버렸다(가운데 배치 + 양쪽 16자 미만으로 교체). 둘 다 가드가 약한 게 아니라 픽스처가 그 경로를 판별하지 못한 경우였다.
|
|
127
|
+
|
|
128
|
+
- **변이 생존 5건의 원인이 세 갈래로 갈렸다** — 등가 변이 1건(가림 제외 목록이 비어 분기가 무의미), **픽스처 간섭** 3건(한 문서에 PEM 을 몰아넣어 서로를 가려 줬다 → 마스킹 단언을 격리 입력으로 직접 검증), **픽스처가 분기에 도달하지 못한** 1건(산문 뒤 base64 가 없어 규칙이 발동조차 안 했다). 앵커가 유일하지 않아 엉뚱한 규칙을 고치던 것도 유일성 검사가 잡았다.
|
|
129
|
+
|
|
130
|
+
- **마스킹을 넓혔으니 실제 데이터로 오탐을 쟀다.** 로컬의 실제 leerness 프로젝트 문서 **189개 · 10,431줄**에 걸어 본 결과, 마스킹된 72줄은 **전부 1.36.95 규칙으로도 이미 마스킹되던 줄**이었다 — 이번 변경이 새로 건드린 줄은 **0건**, 반대로 커버리지가 후퇴한 줄도 **0건**. (그 72줄에는 `idempotency_key=run:phase:step` 같은 **기존 규칙의 오탐**이 섞여 있다. 이번 회귀가 아니므로 별도 항목으로 남겼다.)
|
|
131
|
+
- 검증: 게이트 exit 0 · e2e **416/416** · 신규 1블록 · 변이 **81종 81/81**(등가 2건 제외 · 앵커 문제 0) · 자체 헌트 24종 결함 0 · 출구 전수 13종 · 계약 25종(원시 HTML · 엔티티/제로폭 URL · 표 구분줄 오인 · 미종료 펜스 · 주석 · CDATA) · 성능 6종. 실제 브라우저(788px·375px)에서 탭 전환·7종 내비·본문 렌더 확인.
|
|
132
|
+
- **변이 1차에서 4건이 살아남았고 원인이 넷 다 달랐다** — 변이 하네스의 앵커가 유일하지 않아 엉뚱한 함수를 고친 것(하네스 버그), `mdInline` 도 내부에서 이스케이프하므로 XSS 단언만으론 못 잡는 것(단언이 목적이 아니라 부작용만 봄), 이모지만 반복한 픽스처가 서로게이트 쌍 경계에 정확히 맞아 쪼갤 자리가 없던 것, 줄 경계 단언이 tail 문서에만 걸려 non-tail 변이를 못 본 것. 앵커 유일성 검사를 러너에 넣고 나머지 셋은 픽스처를 고쳤다.
|
|
133
|
+
- **계약 검증 도구 자신이 오탐 5건을 냈다.** 출력 전체에서 `on...=` 를 찾아 `<img src=x onerror=1>` 처럼 **이미 이스케이프된 글자**를 속성으로 오인했다. 그대로 믿었으면 없는 버그를 고쳤을 것이다. 실제 태그 안만 보도록 고치고, **탐지기가 진짜 위반을 잡는지 대조군으로 먼저 증명**한 뒤 "위반 0"을 말한다.
|
|
134
|
+
- **게이트 실패 한 번은 내가 만들었다.** 게이트 실행 중에 소스를 고쳤고, 그 편집이 `String.raw` 템플릿 안 주석에 백틱을 넣어 템플릿을 끊었다 — 바로 그 위에 "여기선 백틱을 쓸 수 없다"고 적어 둔 자리였다. 처음엔 동시 실행 간섭이라고 생각했지만 격리 재현으로 5초 만에 진짜 원인이 나왔다.
|
|
135
|
+
- **검수가 살려낸 마지막 변이는 "데이터는 맞는데 화면이 틀리는" 경우였다.** 코드포인트 단언을 데이터에만 걸어 둬서, 화면 고지를 `String.length` 로 되돌리는 변이가 통과했다. 사용자가 보는 것은 화면이다 — 생성물에서 고지가 어느 값을 쓰는지까지 확인한다.
|
|
136
|
+
|
|
3
137
|
## 1.36.95 — 2026-07-31 — AI 에게 매 세션 읽히던 파일의 절반이 틀린 과거 수치였다
|
|
4
138
|
|
|
5
139
|
사용자 질문에서 시작했다 — "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.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.97 하네스를 사용합니다. 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.
|
|
179
|
+
Leerness v1.36.97는 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.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.97 릴리스 태그 이력** (수백 라운드) · _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.
|
|
238
|
+
Last synced by Leerness v1.36.97: 2026-08-04
|
|
239
239
|
<!-- leerness:project-readme:end -->
|