leerness 1.36.91 → 1.36.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.93 — 2026-07-30 — 검증기가 못 보던 완료 행 · MCP 에서 영영 완료 못 하던 사용자
4
+
5
+ 앞선 헌트에서 확인만 하고 미뤄둔 16건 중 **사용자가 갇히거나 검증 없이 완료되는** 두 건을 처리했다.
6
+
7
+ **`plan add "<작업>" --status verified` 한 줄이 검증기가 보지 못하는 완료 행을 만들었다.** `task add` 는 저장 시 `completed`/`verified` 를 `done` 으로 정규화한다(1.11.3 이 "집계마다 다르게 세는" 불일치를 없애려고 넣은 것). `plan add` 만 값을 검증만 하고 **원문 그대로** 저장했다. 결과는 같은 실행 안에서의 모순이었다 — `lazy detect` 는 그 행을 done 으로 세는데 `verify-claim --all` 과 `gate --claims` 는 `done` 필터에 걸러 **아예 보지 않았다**. `--force` 도 손편집도 필요 없이, 게이트를 통과했다는 신호가 검증되지 않은 주장을 덮었다.
8
+
9
+ - 같은 정규화를 태웠다. 실측: 정규화 안 된 행 0건 · `verify-claim --all` 대상 3 = done 행 수 3(이전 2 vs 3).
10
+ - **정규화는 plan.md 본문의 `Status:` 줄에도 적용된다** — 이건 부수효과가 아니라 함께 고쳐야 할 것이었다. 로드맵 상태 라벨 목록(`ROADMAP_STATUS_LABEL`)에 `verified` 키가 **없어서**, 종전에는 렌더러가 모르는 값이 plan.md 에 적히고 있었다. 덧붙여 상태 이름으로 "verified" 를 스스로 적는 것 자체가 이 제품이 막으려는 **자기선언 검증**이다 — 검증 여부는 검증기가 판정한다.
11
+ - 정규화 클래스를 전수 스윕했다: progress 행에 status 를 쓰는 경로 6곳(task add · plan add · task update · task sync ×2 · 초기 planned) 모두 정규화 또는 안전한 리터럴임을 실행으로 확인.
12
+
13
+ **MCP 표면에서는 정직하게 고친 사용자가 완료를 영영 기록할 수 없었다.** bugfix probe 예산은 600초인데(그래야 `npm test` 같은 현실적인 probe 를 등록할 수 있다) MCP 래퍼는 60초에 자식을 죽였다. 게다가 stdout 이 비어 응답이 **`(no output)`** 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않아 진단 단서가 0 이었고, 재시도할 때마다 테스트가 60초 돌다 죽어 비용만 쌓였다. 하네스가 AI 에게 권하는 1차 표면이 MCP 라 더 나빴다.
14
+
15
+ - 기본 한도를 **660초**(probe 예산 위)로 올리고 `LEERNESS_MCP_TIMEOUT_MS`(5_000~3_600_000)로 조절 가능하게 했다.
16
+ - 타임아웃을 **사실대로** 보고한다 — 무엇이 중단됐는지 · probe 가 최대 600초 걸릴 수 있다는 사실 · 한도 조절 방법 · **동일한 CLI 명령**. MCP 클라이언트 자체의 타임아웃은 leerness 가 어쩌지 못하므로, 해결할 수 없는 부분을 해결한 척하지 않고 대안을 준다.
17
+ - 실측: 한도 5초 + `leerness_selftest`(~20초) → `(no output)` 대신 사유 전달 · 기본 한도에서는 정상 완료(20.8초).
18
+
19
+ **codex 33차 검수가 내 수정이 만든 새 결함을 잡았다(5건 전부 반영):**
20
+
21
+ - **한도를 전역으로 올린 것이 서버를 얼렸다** — `spawnSync` 는 단일 스레드 MCP 서버를 그 시간만큼 통째로 막는다. 무관한 도구가 멎으면 11분간 ping·취소 통지조차 못 읽고, 클라이언트가 자체 타임아웃으로 먼저 포기하면 사용자는 안내를 보지도 못한 채 서버만 9분 더 죽어 있다. → 긴 예산은 **probe 가 실제로 도는 호출에만**(`task update --status done|completed|verified` · `task sync`), 나머지는 60초. 함수 단위 6케이스로 확인.
22
+ - **타임아웃이 아닌 실패를 타임아웃으로 오인했다** — `status == null && !stdout` 은 EPERM·시그널 종료도 같은 모양이라, 권한 문제로 실패한 호출에 "한도를 늘리세요"라는 엉뚱한 처방을 냈다. **`ETIMEDOUT` 만** 타임아웃으로 보고, 나머지는 실제 오류 코드·시그널을 알리며, **중단 전 부분 출력을 버리지 않는다**.
23
+ - probe 설명을 **probe 가 돌 수 있는 호출에만** 붙인다(`leerness_selftest` 타임아웃에 bugfix 원인을 대지 않는다). 안내하는 CLI 명령은 공백 포함 인자를 인용한다.
24
+ - **내가 만든 시한폭탄 대체가 또 다른 시한폭탄이었다** — `patch − 3` 은 `1.37.0`·prerelease·patch<3 에서 0건을 돌려주고 `shown === totalVersions === 0` 이라 **공허하게 통과**한다. CHANGELOG 에 실재하는 버전을 뽑아 쓰고 `shown > 0` 을 함께 단언한다.
25
+ - 타임아웃 단언이 **소스 리터럴만** 봐서 `Math.min(...)` 같은 변조가 통과했다 → 메시지에 찍히는 초 값이 **실제 적용된 한도**와 일치하는지(5초 지정 → "5초")까지 확인하고, 배선(`timeout: budget` · `_needsProbeBudget`)도 함께 단언한다.
26
+
27
+ **시한폭탄 테스트도 하나 걷어냈다.** e2e 가 `whats-new --from 1.36.60` 이 "절단되지 않는다"고 단언했는데, 릴리스가 쌓여 그 창이 기본 상한 30 을 넘겼다(실측 31개 → truncated=true). 제품은 정상이고 **테스트가 스스로 만료**된 것이다. 기준점을 현재 버전에서 3단계 거슬러 자동 산출하게 바꿔, 릴리스가 아무리 쌓여도 창이 유지된다.
28
+
29
+ 게이트: exit 0 · E2E 413/413 · selftest 341/341 · ✗ 0건
30
+
31
+ ## 1.36.92 — 2026-07-30 — 동의 없는 외부 CLI 실행 차단 · 과소평가 우회 3종 · 손상 토글 fail-closed
32
+
33
+ 1.36.88~91 에서 새로 만든 표면(bugfix 게이트·라우팅·대시보드·인증 축)을 6렌즈 적대 헌트(41 에이전트)로 훑어 **21건 확인 / 14건 반증**. 이번 릴리스는 그중 상위 5건 + codex 32차 검수 8건을 반영했고, 나머지 16건은 다음 라운드로 기록해 두었다.
34
+
35
+ **동의 없이 외부 CLI 를 실행하고 있었다(가장 심각).** `agents route "<작업>"` — 토글 OFF · `--confirm` 없음 · `LEERNESS_ENABLE_CODEX` 미설정 — 이 **제안 한 번**에 codex 를 두 번(`--version`, `login status`) 띄웠다. PATH shim 으로 argv 를 로깅해 증명했고, `LEERNESS_ENABLE_CODEX=1` 대조군이 **동일한 spawn** 을 내 문서화된 opt-out 이 이 경로에서 완전한 no-op 임을 확정했다. 공개된 capability 표면과 SECURITY.md 는 "사용자 동의 없이 외부 LLM/API/CLI 를 자동 호출하지 않는다"고 말한다. 원인: 인증 확인이 `plan()` 안에서 돌았고 `plan()` 은 토글·`--confirm`·거부 판정보다 **먼저** 실행된다.
36
+ → 확인은 **확정 요청 + 토글 ON + provider opt-in(`LEERNESS_ENABLE_*=1`)** 이 모두 참일 때만. 제안 단계는 프로세스 0회.
37
+
38
+ **과소평가 우회 3종** — tiny 는 "범위가 좁다"는 주장인데, 규칙이 읽지 못하는 입력에서 그 주장이 통과했다:
39
+ - **다국어**: 고위험 규칙 어휘는 영어·한국어뿐인데 tiny 어휘(css/lint/typo)는 라틴이라 살아남아, `支付退款逻辑修改 css`(중국어 결제 환불)가 **tiny** 로 확정됐다.
40
+ - **전각**: `PAYMENT refund` 이 어떤 규칙에도 안 걸려 normal 로 떨어졌다(반각은 high-risk) → 매칭 전 NFKC 정규화(실측 3500자 200회 4ms).
41
+ - **제로폭**: `pa<ZWSP>yment re<ZWSP>fund css` 로 영문 키워드를 쪼개 우회 → 서식문자 제거.
42
+ - 악센트 라틴(스페인어 등)도 미커버로 본다. **한계는 그대로 적었다**: 악센트 없는 비영어 라틴 문장은 여전히 구분하지 못한다.
43
+
44
+ **손상 토글에서 게이트가 무언 통과했다** — `bugfix-receipt` 는 defaultOff 라 손상 = OFF 다. 앞선 수정은 `toggle list` 에 경고만 달고 **강제 시점을 그대로 뒀다**(codex 32차 #1 BLOCK: "화면에 경고를 띄우는 것과 실제로 막는 것은 다른 일"). 이제 done 전이에서 fail-closed 로 보류하고, `{"bugfix-receipt":"banana"}` 처럼 **JSON 은 멀쩡한데 값을 못 읽는** 경우도 같은 취급을 한다.
45
+
46
+ **그 밖에:**
47
+ - `bugfix start|receipt|drop` 이 위치 경로를 **조용히 무시**했다(`list` 만 인식) — 지목한 프로젝트는 무보호로 done 되고 cwd 의 같은 번호 task 가 갇혔다. 경로형 토큰을 인식하고(UNC 포함), 위치 경로와 `--path` 를 **둘 다** 주면 거부한다.
48
+ - 시크릿 마스킹: `Authorization: Bearer <토큰>` 이 통과하면서 옆에 `***` 가 찍혀 **가려졌다는 거짓 안심**을 줬다(안 가리는 것보다 나쁘다). 짧은 토큰(`Basic YTpi`)과 `~` 포함 token68 을 잡으려 규칙을 넓히면 산문(`Basic authentication flow`)이 훼손되므로, **authorization 헤더 문맥에 앵커**해 값 전체를 지운다 — 우회 7종 차단 · 오탐 0건 실측.
49
+ - 감사 기록은 **입력 원문**을 남긴다(분류용 정규화본이 아니라). 단 git 추적 파일이므로 시크릿 값만 가린다.
50
+ - 인증 미확인 사유를 구분한다 — "이 등급에서는 확인하지 않음" 과 "확인 수단이 등록되지 않음" 은 다른 말인데, 유일하게 확인 명령이 있는 codex 를 "수단 없는 provider" 로 읽게 만들고 있었다.
51
+ - `_EXEC_NOTE` 의 "상태 명령만 실행" 도 부정확했다 — `--version` 도 함께 돈다. 실행 시점과 대상을 정확히 적었다.
52
+ - `--approved-by` 의 제로폭 문자 제거 — 보이지 않는 승인자가 감사 기록에 남지 않게.
53
+
54
+ **가드**: PATH shim 으로 "프로세스를 띄우지 않는다"를 증명한다 — 종전 테스트에는 그걸 증명할 수단이 **아예 없었다**. 제안 0회 / 확정 N회 / opt-out OFF 0회의 3방향 판별을 단언하고, shim 은 배치되는 provider 전부(codex·claude)에 건다. 라우팅 변이 14/14 · 인증축 변이 9/10(1건은 등가 변이, 대조 변이 M9·M10 로 확인).
55
+
56
+ **작업 중 내 대조군이 내 수정을 잡았다**: 정규식 **리터럴**에 백슬래시를 두 개 넣어 `/[\\u0370-\\u03ff]/` 가 되면서 문자 클래스가 `\`·`u`·숫자·`f` 를 뜻하게 됐고, `fix typo in css` 의 `f` 가 "그리스 문자"로 잡혀 tiny 가 normal 로 떨어졌다. 대조군을 함께 돌리지 않았다면 "다국어 방어 강화"라고 보고하면서 멀쩡한 영어 입력을 망가뜨린 채 출하했을 것이다.
57
+
58
+ 게이트: exit 0 · E2E 412/412 · selftest 341/341 · ✗ 0건
59
+
3
60
  ## 1.36.91 — 2026-07-30 — 인증 축: **설치됨 ≠ 사용가능**을 고지에서 실제 판정으로
4
61
 
5
62
  1.36.89 의 라우팅은 "설치·활성만 확인했고 인증·쿼터·과금 권한은 확인하지 않았다"를 **말하기만** 했다. 이번엔 확인할 수 있는 것을 실제로 확인하고, 확인할 수 없는 것은 그대로 남긴다.
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.91 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.93 하네스를 사용합니다. 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.91는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.93는 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.91는 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.91 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.93 릴리스 태그 이력** (수백 라운드) · _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.91: 2026-07-30
238
+ Last synced by Leerness v1.36.93: 2026-07-30
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.91';
37
+ const VERSION = '1.36.93';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -10024,11 +10024,27 @@ let _lastBugfixLines = null; // 통과 시 렌더용 — 직후 호출자가 _
10024
10024
  function _consumeBugfixLines() { const l = _lastBugfixLines; _lastBugfixLines = null; return l; }
10025
10025
  function upsertProgress(root, row) {
10026
10026
  // 게이트는 **락 밖에서** — probe 는 최대 10분까지 도는 사용자 명령이라 progress 락을 물고 있으면 안 된다.
10027
- if (row && row.status === 'done' && _tgl.toggleOn(root, 'bugfix-receipt')) {
10028
- const _bfm = require('../lib/bugfix');
10029
- const _r = _bfm.checkDoneTransition(root, row.id);
10030
- if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id);
10031
- _lastBugfixLines = _r.lines || null;
10027
+ if (row && row.status === 'done') {
10028
+ // 1.36.92 (codex 32차 #1 BLOCK): 손상 토글에서 기본값으로 조용히 되돌아가면 **켜 뒀던 차단 게이트가 꺼진다**
10029
+ // (bugfix-receipt defaultOff → 손상 = OFF). `toggle list` 에만 경고를 달았던 앞선 수정은
10030
+ // **강제 시점을 그대로 뒀다** 정작 막아야 할 곳이 무언 통과였다. 여기서 fail-closed 한다.
10031
+ const _tchk = _tgl.loadTogglesChecked(root);
10032
+ const _tBad = _tchk.corrupt || (_tchk.unreadable || []).includes('bugfix-receipt');
10033
+ if (_tBad) {
10034
+ const _why = _tchk.corrupt ? (_tchk.reason || '형상 무효') : `'bugfix-receipt' 값을 읽을 수 없음`;
10035
+ const _bfm = require('../lib/bugfix');
10036
+ throw new _bfm.BugfixBlocked({
10037
+ code: 'toggles_unreadable',
10038
+ message: `toggles.json 을 신뢰할 수 없어(${_why}) 완료 판정을 보류합니다 — 켜 두었던 게이트가 꺼진 채 통과하는 것을 막습니다: ${_tgl._togglesPath(root)}`,
10039
+ lines: [`복구: 파일을 고치거나 삭제 후 leerness toggle set <id> on|off 로 다시 설정하세요.`],
10040
+ }, row.id);
10041
+ }
10042
+ if (_tchk.toggles['bugfix-receipt'] === true) {
10043
+ const _bfm = require('../lib/bugfix');
10044
+ const _r = _bfm.checkDoneTransition(root, row.id);
10045
+ if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id);
10046
+ _lastBugfixLines = _r.lines || null;
10047
+ }
10032
10048
  }
10033
10049
  // 1.9.303 (UR-0043): read-modify-write 전체를 락으로 직렬화 — 동시 task 쓰기 lost-update 방지.
10034
10050
  return _withLock(progressPath(root), () => {
@@ -10109,7 +10125,12 @@ function planListCmd(root, opts = {}) {
10109
10125
  function planAdd(root, text) {
10110
10126
  if (!_requireInit(root, 'plan add')) return; // 1.9.311 (UR-0047): init 가드
10111
10127
  if (!_validateChoice(arg('--status', null), TASK_STATUSES, 'plan status')) { process.exitCode = 1; return; } // 1.9.310 (UR-0046)
10112
- const status = arg('--status','planned'), progress = arg('--progress','0'), nextAction = arg('--next', _uiLang(root) === 'en' ? 'write the next action' : '다음 액션 작성'); // 1.36.63
10128
+ // 1.36.93 (헌트 #11): `--status` **정규화 없이** progress 행에 그대로 썼다 `plan add "x" --status verified`
10129
+ // `verified` 로 저장되면 lazy detect 는 done 으로 세는데 verify-claim --all / gate --claims 의 done 필터는
10130
+ // 그 행을 통째로 건너뛴다. `--force` 도 손편집도 없이 "검증기가 보지 않는 완료 행"이 만들어졌다.
10131
+ // 1.11.3 이 completed/verified 를 저장 시 done 으로 정규화한 이유가 바로 이 집계 불일치를 없애기 위함인데
10132
+ // plan add 만 그 경로를 타지 않았다. 같은 정규화를 태운다.
10133
+ const status = _normTaskStatus(arg('--status','planned')), progress = arg('--progress','0'), nextAction = arg('--next', _uiLang(root) === 'en' ? 'write the next action' : '다음 액션 작성'); // 1.36.63
10113
10134
  // 1.14.2 (Karpathy 원칙4 "성공기준 정의", UR-0032): --done-when 으로 검증가능 완료조건을 milestone 에 기록. 미지정 시 (미정) — plan show/audit 가 환기.
10114
10135
  const doneWhen = _lineSafe(arg('--done-when', '') || (_uiLang(root) === 'en' ? '(unset)' : '(미정)')); // 1.36.63 (검수 #2)
10115
10136
  // 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
@@ -19938,12 +19959,65 @@ function mcpServeCmd(root) {
19938
19959
  function send(obj) {
19939
19960
  process.stdout.write(JSON.stringify(obj) + '\n');
19940
19961
  }
19962
+ // 1.36.93 (헌트 #8): 래퍼 타임아웃(60s)이 bugfix probe 예산(600s)보다 짧아, 버그를 **진짜 고치고**
19963
+ // 영수증까지 채운 사용자가 MCP 표면에서는 done 을 영영 기록하지 못했다. 게다가 stdout 이 비어 있어
19964
+ // 응답이 `(no output)` 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않았다(진단 단서 0).
19965
+ // ① 기본값을 probe 예산 위로 올리고 ② env 로 조절 가능하게 하며 ③ 타임아웃을 **사실대로** 보고한다.
19966
+ // (MCP 클라이언트 자체 타임아웃은 leerness 가 어쩌지 못한다 — 그래서 메시지에 CLI 대안을 함께 준다.)
19967
+ // codex 33차 #2: 한도를 **전역으로** 올리면 `spawnSync` 가 단일 스레드 서버를 그만큼 얼린다 —
19968
+ // 무관한 도구가 멎어도 11분간 ping·취소 통지조차 못 읽는다. 그래서 긴 예산은 **probe 를 실행할 수 있는
19969
+ // 호출에만** 준다(done 전이 · task sync). 나머지는 종전대로 60초.
19970
+ const MCP_TIMEOUT_DEFAULT_MS = 60000;
19971
+ const MCP_TIMEOUT_PROBE_MS = 660000; // probe 예산(600s) 위 — 이 값이 적용되는 호출은 아래 _needsProbeBudget 뿐
19972
+ const MCP_TIMEOUT_OVERRIDE_MS = (() => {
19973
+ const v = parseInt(process.env.LEERNESS_MCP_TIMEOUT_MS || '', 10);
19974
+ return (Number.isFinite(v) && v >= 5000 && v <= 3600000) ? v : null;
19975
+ })();
19976
+ // bugfix probe 가 도는 경로만 긴 예산을 받는다 — done 전이(task update --status done)와 TodoWrite 왕복(task sync).
19977
+ function _needsProbeBudget(cliArgs) {
19978
+ const a = (cliArgs || []).map(String);
19979
+ if (a[0] !== 'task') return false;
19980
+ if (a[1] === 'sync') return true;
19981
+ if (a[1] !== 'update') return false;
19982
+ const i = a.indexOf('--status');
19983
+ return i >= 0 && ['done', 'completed', 'verified'].includes(a[i + 1]);
19984
+ }
19985
+ function _mcpTimeoutFor(cliArgs) {
19986
+ return MCP_TIMEOUT_OVERRIDE_MS != null ? MCP_TIMEOUT_OVERRIDE_MS
19987
+ : (_needsProbeBudget(cliArgs) ? MCP_TIMEOUT_PROBE_MS : MCP_TIMEOUT_DEFAULT_MS);
19988
+ }
19989
+ const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
19941
19990
  function callLeerness(cliArgs) {
19991
+ const budget = _mcpTimeoutFor(cliArgs);
19942
19992
  const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
19943
19993
  encoding: 'utf8',
19944
- timeout: 60000,
19994
+ timeout: budget,
19945
19995
  env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' }
19946
19996
  });
19997
+ const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
19998
+ const partial = ((r.stdout || '') + (r.stderr || '')).trim();
19999
+ // codex 33차 #1: `status == null && !stdout` 은 EPERM·시그널 종료까지 타임아웃으로 오인하고
20000
+ // 부분 출력을 버렸다. **ETIMEDOUT 만** 타임아웃으로 보고, 나머지 오류는 실제 코드로 알린다.
20001
+ if (r.error && String(r.error.code) === 'ETIMEDOUT') {
20002
+ return {
20003
+ ok: false, exit: null, stdout: '',
20004
+ stderr: `⏱ ${Math.round(budget / 1000)}초 안에 끝나지 않아 중단했습니다: ${cmd}\n`
20005
+ // probe 설명은 probe 가 실제로 돌 수 있는 호출에만 — 무관한 도구에 엉뚱한 원인을 대지 않는다(#3).
20006
+ + (_needsProbeBudget(cliArgs) ? ` · bugfix probe 가 등록된 task 를 done 으로 바꾸면 probe(최대 600초)가 실행됩니다 — 그만큼 시간이 필요합니다.\n` : '')
20007
+ + ` · 한도 조절: LEERNESS_MCP_TIMEOUT_MS=<밀리초> (5000~3600000)\n`
20008
+ + ` · 또는 터미널에서 직접: ${cmd}`
20009
+ + (partial ? `\n · 중단 전 출력:\n${partial.slice(0, 2000)}` : ''),
20010
+ };
20011
+ }
20012
+ if (r.error) {
20013
+ return {
20014
+ ok: false, exit: null, stdout: r.stdout || '',
20015
+ stderr: `✗ 실행 실패(${r.error.code || r.error.message}): ${cmd}` + (partial ? `\n${partial.slice(0, 2000)}` : ''),
20016
+ };
20017
+ }
20018
+ if (r.status == null && r.signal) {
20019
+ return { ok: false, exit: null, stdout: r.stdout || '', stderr: `✗ 시그널 ${r.signal} 로 종료됨: ${cmd}` + (partial ? `\n${partial.slice(0, 2000)}` : '') };
20020
+ }
19947
20021
  return { ok: r.status === 0, exit: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
19948
20022
  }
19949
20023
  function handleRequest(req) {
@@ -24107,7 +24181,11 @@ async function main() {
24107
24181
  const _providerAuth = (pid) => {
24108
24182
  if (_authCache.has(pid)) return _authCache.get(pid);
24109
24183
  const def = (_allProviders(_authRoot) || []).find(a => a.id === pid);
24110
- const v = (def && def.authCheck) ? (_checkAgent(def, { auth: true }).auth || 'unknown') : 'unknown';
24184
+ // 1.36.92 (codex 32차 #2): 문서화된 opt-out 실제로 지킨다 `LEERNESS_ENABLE_<X>=1` 아닌 provider 는
24185
+ // 프로세스를 띄우지 않는다. 종전엔 `enabled` 를 계산만 하고 확인 실행 전에 보지 않아, 외부 도구를 꺼 뒀다고
24186
+ // 믿는 사용자의 환경에서 CLI 가 실행됐다(실측: 플래그 유무와 무관하게 동일 spawn).
24187
+ const _enabled = !!(def && def.envFlag && process.env[def.envFlag] === '1');
24188
+ const v = (def && def.authCheck && _enabled) ? (_checkAgent(def, { auth: true }).auth || 'unknown') : 'unknown';
24111
24189
  _authCache.set(pid, v);
24112
24190
  return v;
24113
24191
  };
@@ -24270,9 +24348,27 @@ async function main() {
24270
24348
  // 등록된 probe 를 "없음"으로 보고했다 — 다른 명령들과 같은 규율로 위치 경로를 받는다.
24271
24349
  // (start/receipt/drop 의 첫 토큰은 task id 이므로, 경로로 오인하지 않도록 list/무인자에서만 적용)
24272
24350
  const _bfSub = _bfToks[0];
24273
- const _bfRest = _bfToks.slice(1);
24351
+ let _bfRest = _bfToks.slice(1);
24274
24352
  let _bfRoot = arg('--path', null);
24275
24353
  if (!_bfRoot && (!_bfSub || _bfSub === 'list')) _bfRoot = _taskPositionalPath(['bugfix', ...(_bfSub ? [_bfSub] : []), ..._bfRest], _bfSub ? 2 : 1);
24354
+ // 1.36.92 (헌트 #9, High): start/receipt/drop 은 위치 경로를 **조용히 무시**했다 — `bugfix start T-0002 <경로>` 가
24355
+ // 지목한 프로젝트가 아니라 cwd 프로젝트에 probe 를 등록해, 지목한 쪽은 무보호로 done 되고 cwd 의 같은 번호
24356
+ // task 는 영문도 모른 채 갇혔다(실측). id 뒤의 경로형 토큰을 인식하고, 인식할 수 없으면 **거부**한다.
24357
+ if (_bfSub === 'start' || _bfSub === 'receipt' || _bfSub === 'drop') {
24358
+ // UNC(\\server\share)도 경로다. 판별 못 하는 토큰은 **거부**한다 — 조용히 버리면 엉뚱한 프로젝트에 쓴다.
24359
+ const _pathLike = (s) => typeof s === 'string' && /^([A-Za-z]:[\\/]|\\\\|\/|\.\.?[\\/])/.test(s);
24360
+ const _stray = _bfRest.slice(1).filter(Boolean);
24361
+ if (_stray.length) {
24362
+ const _p = _stray.find(_pathLike);
24363
+ // codex 32차 #5: 위치 경로와 --path 를 **둘 다** 주면 종전엔 위치 경로를 조용히 버렸다 —
24364
+ // 어느 프로젝트에 쓸지 사용자 의도가 갈리는데 한쪽을 말없이 무시하면 안 된다. 거부한다.
24365
+ if (_p && _stray.length === 1 && arg('--path', null)) {
24366
+ fail(`경로가 두 번 지정됐습니다: 위치 인자 '${_p}' 와 --path '${arg('--path', null)}' — 하나만 쓰세요`); process.exitCode = 1; return;
24367
+ }
24368
+ if (_p && _stray.length === 1) { _bfRoot = absRoot(_p); _bfRest = [_bfRest[0]]; }
24369
+ else { fail(`알 수 없는 인자: ${_stray.join(' ')} — 경로는 마지막에 하나만 두거나 --path <경로> 를 쓰세요 (예: leerness bugfix ${_bfSub} ${_bfRest[0] || '<T-ID>'} --path .)`); process.exitCode = 1; return; }
24370
+ }
24371
+ }
24276
24372
  // _withLock: 동시 등록이 서로를 지우면 게이트가 조용히 꺼진다(실측 4→2)
24277
24373
  // _taskExists: 존재하지 않는 id 로 등록되면 오타 하나로 "보호받는 줄 아는" 무보호 상태가 된다(출하전 헌트 #3)
24278
24374
  return require('../lib/bugfix').bugfixCmd(_bfRoot || process.cwd(), _bfSub, _bfRest,
package/lib/pure-utils.js CHANGED
@@ -13,9 +13,22 @@ function _isSecretKey(k) {
13
13
  // 1.36.91: 대시보드와 인증 확인 evidence 두 곳이 필요해 여기(순수 함수)로 올렸다.
14
14
  // 표면마다 다시 구현하면 한쪽만 고쳐지는 불일치가 생긴다(1.36.90 에서 이미 겪은 클래스).
15
15
  const _SECRET_ASSIGN = /\b([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*[=:]\s*("[^"]*"|'[^']*'|\S+)/gi;
16
- const _SECRET_SHAPE = /\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
16
+ // 1.36.92 (헌트 #13): `Authorization: Bearer <토큰>` 이 통과했다 — 키 이름 뒤 값이 `Bearer` 로 끝나 거기서 멈추고
17
+ // 실제 토큰은 다음 토큰이라 그대로 남았다. 게다가 옆에 `***` 가 찍혀 **가려졌다는 거짓 안심**을 줬다
18
+ // (안 가리는 것보다 나쁘다). 스킴 뒤의 값까지 함께 지운다.
19
+ // 1.36.92 (codex 32차 #4/#6): 종전 규칙은 두 방향으로 틀렸다 —
20
+ // ① 짧은 자격증명을 놓쳤다: `Authorization: Basic YTpi`(= a:b) 가 그대로 남았고, token68 에 허용되는 `~` 가
21
+ // 문자군에 없어 `Bearer abcdefgh~SECRET` 의 뒷부분이 살아남았다.
22
+ // ② 멀쩡한 산문을 훼손했다: `Basic authentication flow` → `Basic *** flow`, `Token release/1.2.3` → `Token ***`.
23
+ // 그래서 **authorization 헤더 문맥에 앵커**하고, 값은 token68 전체를 길이 제한 없이 지운다.
24
+ // (헤더 밖의 맨 `Bearer <값>` 은 아래 SHAPE 규칙이 형태로 잡는다 — 산문 훼손 위험을 감수하지 않는다.)
25
+ const _SECRET_AUTH_HEADER = /\b((?:proxy-)?authorization)([ \t]*[:=][ \t]*)(?:(Bearer|Basic|Token|ApiKey|Digest)([ \t]+))?([A-Za-z0-9._~+/-]+=*)/gi;
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;
17
27
  function redactSecrets(s, max) {
18
- let out = String(s == null ? '' : s).replace(_SECRET_ASSIGN, (m, k) => `${k}=***`).replace(_SECRET_SHAPE, '***');
28
+ let out = String(s == null ? '' : s)
29
+ .replace(_SECRET_AUTH_HEADER, (m, key, sep, scheme, gap) => `${key}${sep}${scheme ? scheme + (gap || ' ') : ''}***`)
30
+ .replace(_SECRET_ASSIGN, (m, k) => `${k}=***`)
31
+ .replace(_SECRET_SHAPE, '***');
19
32
  if (max && out.length > max) out = out.slice(0, max) + '…';
20
33
  return out;
21
34
  }
package/lib/routing.js CHANGED
@@ -53,18 +53,51 @@ const TINY = [
53
53
  const BROAD = /\b(entire|whole|all |everything|rewrite|overhaul|refactor all|across the|end.to.end)\b|전체|전면|모든|갈아엎|재작성|통째/i;
54
54
  const TINY_MAX_LEN = 80;
55
55
 
56
- function _norm(s) { return String(s == null ? '' : s).trim(); }
56
+ // 1.36.92 (자체 적대 검증): 전각 문자로 고위험 규칙을 우회할 있었다
57
+ // `PAYMENT refund` 는 어떤 규칙에도 안 걸려 normal 로 떨어졌다(반각은 high-risk).
58
+ // 매칭 전에 NFKC 로 정규화한다(실측 비용: 3500자 200회에 4ms). 표시·기록은 원문을 쓴다.
59
+ function _norm(s) {
60
+ const raw = String(s == null ? '' : s).trim();
61
+ let t = raw;
62
+ try { t = raw.normalize('NFKC'); } catch { t = raw; }
63
+ // codex 32차 #3: 제로폭 문자를 끼워 넣으면 영문 키워드가 쪼개져 고위험 규칙을 통째로 빠져나간다
64
+ // (예: pa<ZWSP>yment re<ZWSP>fund css → tiny). 매칭 전에 서식문자를 제거한다.
65
+ return t.replace(new RegExp('[\\u200b-\\u200f\\u2060\\ufeff\\u00ad]', 'g'), '');
66
+ }
67
+
68
+ // 규칙이 커버하는 문자 체계는 라틴(영문)과 한글뿐이다 — 그 외가 섞이면 고위험 규칙이 침묵한다는 뜻이므로,
69
+ // tiny 로 내리는 근거가 될 수 없다. 어떤 체계가 섞였는지 이름을 돌려 사용자에게 사유를 그대로 보여준다.
70
+ const _SCRIPTS = [
71
+ ['일본어', /[぀-ヿ]/],
72
+ ['중국어(한자)', /[一-鿿]/],
73
+ ['키릴', /[Ѐ-ӿ]/],
74
+ ['아랍어', /[؀-ۿ]/],
75
+ ['히브리어', /[֐-׿]/],
76
+ ['태국어', /[฀-๿]/],
77
+ ['데바나가리', /[ऀ-ॿ]/],
78
+ ['그리스', /[\u0370-\u03ff]/],
79
+ // codex 32차 #3: "라틴 문자면 커버된다"는 가정이 틀렸다 — 규칙 어휘는 **영어**뿐이라
80
+ // "modificar lógica de reembolso de pago css"(스페인어 결제 환불) 가 tiny 로 떨어졌다.
81
+ // 악센트 라틴이 섞이면 영어가 아닐 가능성이 크므로 tiny 주장을 인정하지 않는다.
82
+ // 한계(정직하게): 악센트 없는 비영어 라틴 문장(예: "pago reembolso css")은 여전히 구분하지 못한다.
83
+ ['비영어 라틴(악센트)', /[\u00c0-\u024f]/],
84
+ ];
85
+ function _hasUncoveredScript(t) {
86
+ const hit = _SCRIPTS.find(([, re]) => re.test(t));
87
+ return hit ? hit[0] : null;
88
+ }
57
89
 
58
90
  // 사용자에게 반복 노출되는 고지 — **이 모듈이 실제로 한 일만** 말한다.
59
91
  // 1.36.89 (codex 28차 #7): 종전 문구는 "설치·활성 여부만 확인했습니다"였는데, 이 모듈은 역할 매핑만 읽고
60
92
  // 설치 확인(`--version`)을 **한 번도 하지 않는다**. 하지 않은 확인을 했다고 말하는 것이 바로 이 제품이 잡는 결함이다.
61
93
  // 1.36.91: 인증 축이 생겼다 — 확인 가능한 provider 만 판정하고 나머지는 '확인안됨' 이다. 쿼터·과금 권한은 여전히 아무도 확인하지 않는다.
62
- const _AVAIL_NOTE = '역할별 인증 표시는 무료·비대화형 확인 명령이 있는 provider 만 판정한 결과입니다(나머지는 "확인안됨") — 모델 사용권한·쿼터·과금 권한은 어느 provider 도 확인하지 않았습니다(상세: leerness agents check).';
94
+ const _AVAIL_NOTE = '인증 확인은 **--confirm 으로 고위험 구성을 확정할 때만** 실행합니다(제안 단계에서는 외부 CLI 를 띄우지 않습니다). 확인하더라도 무료·비대화형 확인 명령이 등록된 provider 만 판정되며, 모델 사용권한·쿼터·과금 권한은 어느 provider 도 확인하지 않습니다(상세: leerness agents check).';
63
95
  const _CLASS_NOTE = '난이도는 텍스트 규칙 매칭 결과이며 측정값이 아닙니다.';
64
96
  // 1.36.91 (codex 30차 #3): 인증 축이 생기면서 이 문구가 **거짓이 됐다** — 인증 확인은 외부 CLI 를
65
97
  // 실제로 실행한다(`<bin> --version` · 등록된 status 명령). "모델을 호출하지 않는다"와 "프로세스를 전혀
66
98
  // 띄우지 않는다"는 다른 말이고, 전자만 참이다. 문구를 사실에 맞춘다.
67
- const _EXEC_NOTE = 'leerness 여기서 작업을 대신 수행하지 않습니다 계획을 확정하고 기록할 뿐이며, 모델 호출(과금)은 하지 않습니다. (고위험 등급에서는 인증 확인을 위해 해당 CLI 의 상태 명령만 실행합니다.)';
99
+ // 1.36.92 (헌트 #3): "상태 명령만 실행" 부정확했다인증 확인은 `--version` 함께 돌린다(감지용).
100
+ const _EXEC_NOTE = 'leerness 는 여기서 작업을 대신 수행하지 않습니다 — 계획을 확정하고 기록할 뿐이며, 모델 호출(과금)은 하지 않습니다. (고위험 구성을 --confirm 으로 확정할 때만, 인증 확인을 위해 해당 CLI 의 버전 확인과 로그인 상태 명령을 실행합니다.)';
68
101
 
69
102
  // 난이도 판정 — 순서가 곧 정책이다. (1) 사용자 명시 (2) 고위험 도메인 (3) 초소형 (4) 기본 normal
70
103
  function classify(text, opts = {}) {
@@ -111,6 +144,15 @@ function classify(text, opts = {}) {
111
144
  // tiny 는 "범위가 좁다"는 주장이다 — 광범위 신호가 있거나 설명이 길면 그 주장을 인정하지 않는다.
112
145
  const broad = BROAD.test(t);
113
146
  const longText = t.length > TINY_MAX_LEN;
147
+ // 1.36.92 (헌트 #15, High): 고위험 규칙은 **영어·한국어 어휘만** 담고 있다. 설명이 다른 언어로 쓰이면
148
+ // 고위험 규칙은 통째로 침묵하는데, tiny 어휘(css/lint/typo/rename/format)는 라틴 문자라 그대로 살아남아
149
+ // 결제·인증·전체삭제 작업이 **tiny 로 확정**됐다(실측). 규칙이 못 읽는 언어에서 "좁다"고 단정할 근거가 없다.
150
+ // → 커버하지 않는 문자 체계가 섞여 있으면 tiny 주장을 인정하지 않는다(normal 로 보수 처리).
151
+ const uncovered = _hasUncoveredScript(t);
152
+ if (uncovered) {
153
+ reasons.push(`고위험 규칙이 다루지 않는 문자 체계(${uncovered})가 섞여 있습니다 — 규칙이 읽지 못하는 언어에서는 "범위가 좁다"고 단정할 수 없어 normal 로 둡니다. 실제로 사소하면 --tier tiny 로 지정하세요`);
154
+ return { ok: true, tier: 'normal', confidence: 'low', signals, reasons, conflict: true };
155
+ }
114
156
  if (broad || longText) {
115
157
  reasons.push(`초소형 신호(${tiny.map(r => r.ko).join(', ')})가 있지만 ${broad ? '광범위 신호가 함께 있어' : `설명이 ${t.length}자로 길어(기준 ${TINY_MAX_LEN}자)`} 좁은 범위로 볼 수 없습니다 — normal 로 둡니다`);
116
158
  return { ok: true, tier: 'normal', confidence: 'low', signals, reasons, conflict: true };
@@ -151,8 +193,16 @@ function plan(root, cls, deps = {}) {
151
193
  // 거의 모든 고위험 작업이 잠긴다(false-BLOCK). 확실히 아닌 것만 막고, 모르는 것은 모른다고 표시한다.
152
194
  // codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다(provider 당 최대 version 5s + status 8s).
153
195
  // 차단에 쓰이는 곳은 고위험뿐이므로 **고위험에서만** 확인한다 — tiny/normal 은 지연도 실행도 없다.
154
- const authOf = (spec.humanApproval && typeof deps._providerAuth === 'function') ? deps._providerAuth : () => 'unknown';
155
- assignments.forEach(a => { a.auth = a.resolved ? authOf(a.provider) : null; });
196
+ // 1.36.92 (헌트 #2/#3): 그런데 **제안만 하는 호출에서도** 확인이 돌았다 토글 OFF·`--confirm` 없음·
197
+ // `LEERNESS_ENABLE_*` 미설정 상태에서 `agents route "..."` 번이 codex 를 두 번 띄웠다(실측 shim 로그).
198
+ // 공개된 opt-out("LEERNESS_ENABLE_* 미설정 시 비활성")과 "사용자 동의 없이 외부 CLI 를 자동 호출하지 않는다"는
199
+ // 주장에 정면으로 어긋난다. 그래서 **확정 요청(--confirm)일 때만** 확인한다 — 제안은 프로세스를 띄우지 않는다.
200
+ const mayCheck = spec.humanApproval && deps.authAllowed === true && typeof deps._providerAuth === 'function';
201
+ const authOf = mayCheck ? deps._providerAuth : () => 'unknown';
202
+ // 왜 'unknown' 인지 사유를 구분한다 — 헌트 #1: 종전엔 tiny/normal 에서도 "확인 명령이 없어서"라고 설명해,
203
+ // 유일하게 확인 명령이 등록된 codex 를 "확인 수단 없는 provider"로 읽게 만들었다.
204
+ const authReason = mayCheck ? 'checked' : 'not-checked-at-this-tier';
205
+ assignments.forEach(a => { a.auth = a.resolved ? authOf(a.provider) : null; a.authReason = a.resolved ? authReason : null; });
156
206
  const notAuthed = assignments.filter(a => a.resolved && a.auth === 'no').map(a => `${a.role}:${a.provider}`);
157
207
 
158
208
  const blockers = [];
@@ -208,11 +258,14 @@ function routeCmd(root, taskText, deps = {}) {
208
258
 
209
259
  const cls = classify(taskText, { tier: (arg ? arg('--tier', '') : '') || '' });
210
260
  if (!cls.ok) { failJson(json, cls.code, cls.error); return; }
211
- const p = plan(root, cls, deps);
212
-
213
261
  const wantDispatch = !!(has && (has('--confirm') || has('--dispatch')));
214
- const approvedBy = _norm(arg ? arg('--approved-by', '') : '');
215
262
  const gateOn = typeof deps._toggleOn === 'function' ? deps._toggleOn(root, 'difficulty-routing') : false;
263
+ // 인증 확인은 외부 프로세스를 띄운다 — **확정을 요청했고 토글도 켜져 있을 때만** 허용한다.
264
+ // codex 32차 #7: 종전엔 확정 요청만 보아, 토글이 꺼져 **어차피 거부될 호출**에서도 CLI 를 실행했다.
265
+ const p = plan(root, cls, Object.assign({}, deps, { authAllowed: wantDispatch && gateOn }));
266
+ // 승인자는 정규화(NFKC)하지 않은 원문을 쓰되, 보이지 않는 문자만 제거한다 — 제로폭 문자로 채운 값이
267
+ // "승인자 있음"으로 통과하면 감사 기록에 보이지 않는 승인자가 남는다.
268
+ const approvedBy = String((arg ? arg('--approved-by', '') : '') || '').replace(new RegExp('[\\u200b-\\u200f\\u2060\\ufeff]', 'g'), '').trim();
216
269
 
217
270
  // 실행 거부 사유 — 하나라도 있으면 dispatch 하지 않는다(고위험은 fail-closed).
218
271
  const refusals = [];
@@ -226,7 +279,9 @@ function routeCmd(root, taskText, deps = {}) {
226
279
  const confirmed = wantDispatch && !refusals.length;
227
280
  const rec = {
228
281
  at: now(),
229
- task: _norm(taskText).slice(0, 300),
282
+ // 1.36.92: 감사 기록은 **사용자가 실제로 입력한 문자열**을 남긴다(분류용 NFKC 정규화본이 아니라).
283
+ // 다만 이 파일은 git 에 추적되므로 시크릿은 값만 가린다(헌트 #6 — 대시보드와 같은 술어).
284
+ task: require('./pure-utils').redactSecrets(String(taskText == null ? '' : taskText).trim(), 300),
230
285
  tier: cls.tier, confidence: cls.confidence, conflict: !!cls.conflict,
231
286
  signals: cls.signals.map(s => `${s.kind}:${s.id}`),
232
287
  assignments: p.assignments.map(a => ({ role: a.role, provider: a.provider, model: a.model })),
@@ -255,7 +310,9 @@ function routeCmd(root, taskText, deps = {}) {
255
310
  cls.reasons.forEach(r => log(` · ${r}`));
256
311
  log(`\n 구성(${p.ko}):`);
257
312
  p.assignments.forEach(a => {
258
- const authMark = a.auth === 'ok' ? ' [인증 확인됨]' : a.auth === 'no' ? ' [🔴 미인증]' : a.resolved ? ' [인증 확인안됨]' : '';
313
+ // 사유를 구분해 표시한다 "확인 함"과 "확인할 수단이 없음"은 다른 말이다(헌트 #1).
314
+ const authMark = a.auth === 'ok' ? ' [인증 확인됨]' : a.auth === 'no' ? ' [🔴 미인증]'
315
+ : a.resolved ? (a.authReason === 'checked' ? ' [인증 확인 수단 없음]' : ' [인증 미확인 — 이 등급에서는 확인하지 않음]') : '';
259
316
  log(` ${a.role.padEnd(10)} ${a.resolved ? `${a.provider}${a.model ? ' / ' + a.model : ''}${authMark}` : '⚠ 미설정 — leerness roles set ' + a.role + ' --provider <id>'}`);
260
317
  });
261
318
  if (p.reviewerIndependent === false) log(` ⚠ 검수자와 구현자가 같은 provider — 독립 검수가 아닙니다`);
package/lib/toggles.js CHANGED
@@ -39,19 +39,32 @@ function _coerceToggle(v, dflt) {
39
39
  return dflt;
40
40
  }
41
41
 
42
- function loadToggles(root) {
42
+ // 1.36.92 (헌트 #5): 손상 파일에서 조용히 기본값으로 돌아가면 **켜 뒀던 차단 게이트가 무경고로 꺼진다**
43
+ // (bugfix-receipt 는 defaultOff 라 손상 = OFF). 사용자는 `toggle list` 에서도 그냥 "OFF" 만 본다.
44
+ // 손상 사실을 함께 돌려주고, 호출부가 그걸 사용자에게 알린다. (읽기 경로는 계속 fail-open 이지만 **조용하지 않다**.)
45
+ function loadTogglesChecked(root) {
43
46
  const out = {};
44
47
  for (const [id, meta] of Object.entries(TOGGLE_REGISTRY)) out[id] = !(meta && meta.defaultOff);
45
48
  const f = _togglesPath(root);
46
- if (!exists(f)) return out;
49
+ if (!exists(f)) return { toggles: out, corrupt: false, unreadable: [] };
47
50
  try {
48
51
  const j = JSON.parse(read(f));
49
- if (!j || typeof j !== 'object' || Array.isArray(j)) return out; // 배열/스칼라 저장본 기본값
50
- for (const [k, v] of Object.entries(j)) if (k in out) out[k] = _coerceToggle(v, out[k]);
51
- } catch {}
52
- return out;
52
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return { toggles: out, corrupt: true, reason: '최상위가 객체가 아닙니다', unreadable: [] };
53
+ // codex 32차 #1: JSON 멀쩡한데 **값을 읽는** 경우(`"banana"`) 저장 의도가 유실된 것이다 —
54
+ // 기본값으로 조용히 되돌리면 켜 뒀던 차단 게이트가 꺼진다. 어떤 키가 그랬는지 함께 돌려준다.
55
+ const unreadable = [];
56
+ for (const [k, v] of Object.entries(j)) {
57
+ if (!(k in out)) continue;
58
+ const coerced = _coerceToggle(v, out[k]);
59
+ if (_coerceToggle(v, true) !== _coerceToggle(v, false)) unreadable.push(k); // 기본값에 따라 답이 갈리면 = 못 읽은 값
60
+ out[k] = coerced;
61
+ }
62
+ return { toggles: out, corrupt: false, unreadable };
63
+ } catch { return { toggles: out, corrupt: true, reason: 'JSON 파싱 실패', unreadable: [] }; }
53
64
  }
54
65
 
66
+ function loadToggles(root) { return loadTogglesChecked(root).toggles; }
67
+
55
68
  // 단일 토글 조회 헬퍼 — 호출부 한 줄용. defaultOff 토글은 명시적으로 켜야 true.
56
69
  function toggleOn(root, id) {
57
70
  const st = loadToggles(root);
@@ -71,10 +84,13 @@ function toggleCmd(root, sub, id, val, deps = {}) {
71
84
  const { has, VERSION } = deps;
72
85
  root = absRoot(root);
73
86
  const json = has && has('--json');
74
- const cur = loadToggles(root);
87
+ const _chk = loadTogglesChecked(root);
88
+ const cur = _chk.toggles;
75
89
  if (!sub || sub === 'list') {
76
- if (json) { log(JSON.stringify({ version: VERSION, toggles: cur, registry: TOGGLE_REGISTRY }, null, 2)); return; }
90
+ if (json) { log(JSON.stringify({ version: VERSION, toggles: cur, corrupt: !!_chk.corrupt, corruptReason: _chk.reason, registry: TOGGLE_REGISTRY }, null, 2)); return; }
77
91
  log(`# leerness toggle — 기능 토글 (온톨로지 그래프 뷰 ⚙ 탭과 연동)`);
92
+ // 손상 시 "그냥 OFF" 로 보이면 켜 뒀던 게이트가 꺼진 줄 모른다 — 사실을 먼저 알린다(헌트 #5).
93
+ if (_chk.corrupt) log(` ⚠ toggles.json 손상(${_chk.reason}) — 아래는 **저장값이 아니라 기본값**입니다. 켜 두었던 토글이 꺼져 보일 수 있습니다: ${_togglesPath(root)}`);
78
94
  for (const [k, meta] of Object.entries(TOGGLE_REGISTRY)) {
79
95
  log(` ${cur[k] ? '🟢 ON ' : '⚪ OFF'} ${k.padEnd(17)} ${meta.desc} [${meta.affects}]`);
80
96
  }
@@ -101,4 +117,4 @@ function toggleCmd(root, sub, id, val, deps = {}) {
101
117
  fail(`알 수 없는 하위명령: ${sub} (가능: list, set)`); process.exitCode = 1;
102
118
  }
103
119
 
104
- module.exports = { TOGGLE_REGISTRY, loadToggles, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
120
+ module.exports = { TOGGLE_REGISTRY, loadToggles, loadTogglesChecked, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.91",
3
+ "version": "1.36.93",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -7351,8 +7351,17 @@ total++;
7351
7351
  // 무효 limit 은 기본값 폴백 (0/-5/abc)
7352
7352
  const badLim = ['0', '-5', 'abc'].every(v => { try { return JSON.parse(R(['--from', '1.9.33', '--json', '--limit', v]).stdout).shown === 30; } catch { return false; } });
7353
7353
  // 절단 없을 때 hint 부재
7354
- const small = JSON.parse(R(['--from', '1.36.60', '--json']).stdout);
7355
- const smallOk = small.truncated === false && small.hint === undefined && small.shown === small.totalVersions;
7354
+ // 1.36.93: `--from` 하드코딩(1.36.60)했더니 **릴리스가 쌓여 기본 상한 30 을 넘는 순간 만료**됐다
7355
+ // (실측: 1.36.60 이후 31개 truncated=true "절단 없음" 단언이 깨졌다). 제품 결함이 아니라
7356
+ // 시한폭탄 테스트다. 현재 버전에서 몇 단계만 거슬러 잡아 상한에 걸리지 않게 자동 산출한다.
7357
+ // codex 33차 #4: 버전 계산(patch-3)은 `1.37.0`·prerelease·patch<3 에서 **0건**을 돌려주고
7358
+ // `shown === totalVersions === 0` 이라 공허하게 통과한다. CHANGELOG 에 **실재하는** 최근 버전을 골라 쓰고,
7359
+ // 결과가 비어 있지 않은지(shown > 0)까지 단언한다.
7360
+ const _chg = fs.readFileSync(path.join(path.dirname(CLI), '..', 'CHANGELOG.md'), 'utf8');
7361
+ const _vers = (_chg.match(/^## (\d+\.\d+\.\d+)/gm) || []).map(s => s.replace('## ', ''));
7362
+ const _fromRecent = _vers[Math.min(3, _vers.length - 1)] || _vers[0];
7363
+ const small = JSON.parse(R(['--from', _fromRecent, '--json']).stdout);
7364
+ const smallOk = small.truncated === false && small.hint === undefined && small.shown === small.totalVersions && small.shown > 0;
7356
7365
  // MCP 인자 전달 (limit)
7357
7366
  const mcpReq = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_whats_new', arguments: { from: '1.9.33', limit: 2 } } });
7358
7367
  const mcpR = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 25000, input: mcpReq + '\n', maxBuffer: 32 * 1024 * 1024 });
@@ -7801,15 +7810,18 @@ total++;
7801
7810
  const human = R(['agents', 'route', '주문 필터', '--path', d]).o;
7802
7811
  const logHuman = R(['agents', 'route', '--log', '--path', d]).o;
7803
7812
  const cj = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
7804
- const honestOk = /확인하지 않았습니다/.test(human) && /측정값이 아닙니다/.test(human)
7813
+ const honestOk = /확인하지 않습니다/.test(human) && /측정값이 아닙니다/.test(human)
7805
7814
  && !/설치·활성 여부만 확인/.test(human) // 하지 않은 확인을 했다고 말하던 옛 문구
7806
- && /확인하지 않았습니다/.test(logHuman) && /작업을 대신 수행하지 않습니다/.test(logHuman)
7815
+ && /확인하지 않습니다/.test(logHuman) && /작업을 대신 수행하지 않습니다/.test(logHuman)
7807
7816
  && cj.executed === false && typeof cj.confirmed === 'boolean' && !('dispatched' in cj)
7808
- // 1.36.91: 인증 축이 생기면서 "외부 CLI 를 전혀 실행하지 않는다"는 **거짓이 됐다**(고위험에서 상태 명령을 실행한다).
7809
- // 문구가 구분을 담고 있는지 단언하고, 과장 문구가 되살아나지 않는지도 함께 본다.
7817
+ // 1.36.91: 인증 축이 생기면서 "외부 CLI 를 전혀 실행하지 않는다"는 **거짓이 됐다**(확정 상태 명령을 실행한다).
7818
+ // 1.36.92: "상태 명령만" 부정확했다 `--version` 함께 돈다. 문구가 실행 시점과 대상을 정확히
7819
+ // 말하는지 단언하고, **옛 과장 문구 두 형태가 되살아나지 않는지**도 함께 본다.
7810
7820
  && /모델 호출\(과금\)은 하지 않습니다/.test(JSON.stringify(cj))
7811
- && /인증 확인을 위해 해당 CLI 의 상태 명령만 실행/.test(JSON.stringify(cj))
7812
- && !/외부 CLI 실행하지 않습니다/.test(JSON.stringify(cj));
7821
+ && /--confirm 으로 확정할 때만/.test(JSON.stringify(cj))
7822
+ && /버전 확인과 로그인 상태 명령을 실행/.test(JSON.stringify(cj))
7823
+ && !/외부 CLI 를 실행하지 않습니다/.test(JSON.stringify(cj))
7824
+ && !/상태 명령만 실행/.test(JSON.stringify(cj));
7813
7825
  // 모델명 비하드코딩 — 정책 모듈에 벤더 모델 id 가 있으면 안 된다(몇 달이면 낡는다)
7814
7826
  // 경로는 CLI 기준으로 잡는다 — 검사 대상이 "지금 실행 중인 그 설치본"이어야 한다(__dirname 은 실행 위치에 따라 어긋난다).
7815
7827
  const srcOk = !/(claude-(opus|sonnet|haiku)|gpt-[45]|o[34]-mini|gemini-)/i.test(fs.readFileSync(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'), 'utf8'));
@@ -7979,25 +7991,32 @@ total++;
7979
7991
  // A4~A6: 라우팅 연동 — unknown 은 통과, 확실한 미인증만 고위험에서 차단, normal 은 무관
7980
7992
  const RTm = require(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'));
7981
7993
  const roles = { architect: 'codex', commander: 'codex', coder: 'codex', reviewer: 'claude' };
7982
- const planWith = (tier, authMap) => RTm.plan(d, { tier }, {
7994
+ // 1.36.92: 인증 확인은 **확정 요청(authAllowed)** 때만 돈다 제안이 외부 프로세스를 띄우던 동의 위반을 막았다.
7995
+ // 그래서 차단 판정을 보려면 authAllowed:true 를 줘야 하고, 주지 않으면 확인 자체가 없어야 한다(판별 대조).
7996
+ const planWith = (tier, authMap, allowed = true) => RTm.plan(d, { tier }, {
7997
+ authAllowed: allowed,
7983
7998
  _resolveRole: (r, role) => ({ provider: roles[role] }),
7984
7999
  _providerAuth: (pid) => authMap[pid] || 'unknown',
7985
8000
  });
7986
8001
  const hasAuthBlock = (p) => p.blockers.some(b => b.code === 'provider_not_authenticated');
7987
- A4 = !hasAuthBlock(planWith('high-risk', {})); // 전부 unknown → 막지 않는다
8002
+ A4 = !hasAuthBlock(planWith('high-risk', {})) // 전부 unknown → 막지 않는다
8003
+ && !hasAuthBlock(planWith('high-risk', { codex: 'no' }, false)); // 확정 요청 없으면 확인도 차단도 없다
7988
8004
  A5 = hasAuthBlock(planWith('high-risk', { codex: 'no', claude: 'ok' })); // 확실한 미인증 → 막는다
7989
8005
  // codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다 — 차단에 쓰이는 고위험에서만 확인해야
7990
8006
  // tiny/normal 에 지연이 붙지 않고, "여기서 CLI 를 실행한다"는 범위도 좁게 유지된다.
7991
8007
  // 호출 횟수를 직접 센다(결과만 보면 "확인은 하고 무시하는" 구현과 구분되지 않는다).
7992
8008
  let calls = 0;
7993
- const counting = (tier) => RTm.plan(d, { tier }, {
8009
+ const counting = (tier, allowed) => RTm.plan(d, { tier }, {
8010
+ authAllowed: allowed,
7994
8011
  _resolveRole: (r, role) => ({ provider: roles[role] }),
7995
8012
  _providerAuth: () => { calls++; return 'no'; },
7996
8013
  });
7997
- calls = 0; const normalPlan = counting('normal'); const normalCalls = calls;
7998
- calls = 0; counting('high-risk'); const highCalls = calls;
8014
+ calls = 0; const normalPlan = counting('normal', true); const normalCalls = calls;
8015
+ calls = 0; counting('high-risk', true); const highCalls = calls;
8016
+ calls = 0; counting('high-risk', false); const noConsentCalls = calls;
7999
8017
  A6 = !hasAuthBlock(planWith('normal', { codex: 'no', claude: 'no' })) // normal 은 무관
8000
8018
  && !hasAuthBlock(normalPlan) && normalCalls === 0 && highCalls > 0 // normal 은 확인 자체를 안 한다
8019
+ && noConsentCalls === 0 // 확정 요청 없으면 고위험도 확인 안 한다(동의)
8001
8020
  && planWith('high-risk', {}).assignments.every(a => 'auth' in a); // auth 축이 계획에 실린다
8002
8021
  ok = A1 && A2 && A3 && A4 && A5 && A6;
8003
8022
  if (!ok) console.log(` [인증축 디버그] 축노출=${A1} 미확인은unknown=${A2} list무확인=${A3} unknown통과=${A4} 미인증차단=${A5} normal무관+축노출=${A6}`);
@@ -8007,6 +8026,149 @@ total++;
8007
8026
  if (!ok) failed++;
8008
8027
  }
8009
8028
 
8029
+ // 1.36.92 (신규표면 헌트 확정 5건): 동의 없는 외부 CLI 실행 · 사유 오귀인 · 다국어 과소평가 ·
8030
+ // 위치경로 무시 · Bearer 토큰 미마스킹 · 손상 토글 무경고.
8031
+ // **PATH shim** 으로 "프로세스를 띄우지 않는다"를 증명한다 — 종전 테스트에는 그걸 증명할 수단이 아예 없었다.
8032
+ total++;
8033
+ {
8034
+ let ok = false;
8035
+ const _d = [];
8036
+ let N1 = false, N2 = false, N3 = false, N4 = false, N5 = false;
8037
+ try {
8038
+ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-shim-')); _d.push(shimDir);
8039
+ const shimLog = path.join(shimDir, 'calls.log');
8040
+ const isWin = process.platform === 'win32';
8041
+ // codex 32차 #8: shim 이 codex 하나뿐이면 다른 provider 로 새는 회귀를 못 잡는다 — 배치된 provider 전부에 건다.
8042
+ for (const binName of ['codex', 'claude']) {
8043
+ if (isWin) fs.writeFileSync(path.join(shimDir, `${binName}.cmd`), `@echo off\r\n>>"${shimLog}" echo ${binName} %*\r\nif "%1"=="--version" (echo ${binName}-shim 1.0.0& exit /b 0)\r\necho Logged in using shim\r\nexit /b 0\r\n`);
8044
+ else { const p = path.join(shimDir, binName); fs.writeFileSync(p, `#!/bin/sh\necho "${binName} $@" >> "${shimLog}"\nif [ "$1" = "--version" ]; then echo "${binName}-shim 1.0.0"; exit 0; fi\necho "Logged in using shim"\nexit 0\n`); try { fs.chmodSync(p, 0o755); } catch {} }
8045
+ }
8046
+ // provider opt-in 을 **켠 상태**로 돌린다 — 그래야 "확정에서는 실행된다"는 대조가 성립하고,
8047
+ // 아래 N1b 가 "끄면 실행되지 않는다"를 별도로 확인한다(문서화된 opt-out 의 실제 검증).
8048
+ const env = Object.assign({}, process.env, {
8049
+ PATH: shimDir + path.delimiter + process.env.PATH,
8050
+ LEERNESS_ENABLE_CODEX: '1', LEERNESS_ENABLE_CLAUDE: '1',
8051
+ });
8052
+ const envOff = Object.assign({}, env, { LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_CLAUDE: '0' });
8053
+ const RS = (a, e) => { const r = cp.spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', timeout: 120000, env: e || env }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
8054
+ const calls = () => { try { return fs.readFileSync(shimLog, 'utf8').trim().split('\n').filter(Boolean).length; } catch { return 0; } };
8055
+ const clear = () => { try { fs.rmSync(shimLog, { force: true }); } catch {} };
8056
+
8057
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h2-')); _d.push(d);
8058
+ RS(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
8059
+ ['commander', 'coder', 'architect'].forEach(x => RS(['roles', 'set', x, '--provider', 'codex', '--path', d]));
8060
+ RS(['roles', 'set', 'reviewer', '--provider', 'claude', '--path', d]);
8061
+ // N1: **제안은 외부 프로세스를 띄우지 않는다**(동의 전 실행 금지). 확정에서는 띄운다 — 판별 대조.
8062
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--path', d]);
8063
+ const suggestCalls = calls();
8064
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--path', d, '--json']);
8065
+ const suggestJsonCalls = calls();
8066
+ RS(['toggle', 'set', 'difficulty-routing', 'on', '--path', d]);
8067
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--confirm', '--approved-by', '검증자', '--path', d]);
8068
+ const confirmCalls = calls();
8069
+ // N1b: **문서화된 opt-out 을 실제로 지키는지** — LEERNESS_ENABLE_* 를 끄면 확정에서도 띄우지 않는다.
8070
+ // (종전엔 이 플래그가 이 경로에서 완전한 no-op 이라, 외부 도구를 껐다고 믿는 사용자 환경에서 CLI 가 돌았다.)
8071
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--confirm', '--approved-by', '검증자', '--path', d], envOff);
8072
+ const optOutCalls = calls();
8073
+ N1 = suggestCalls === 0 && suggestJsonCalls === 0 && confirmCalls > 0 && optOutCalls === 0;
8074
+ // N2: "확인 안 함"과 "확인 수단 없음"을 구분해 말한다(같은 provider 가 등급에 따라 갈리던 오귀인)
8075
+ const tinyOut = RS(['agents', 'route', 'fix typo in button label', '--path', d]).o;
8076
+ N2 = /이 등급에서는 확인하지 않음/.test(tinyOut)
8077
+ && !/무료·비대화형 확인 명령이 있는 provider 만 판정한 결과/.test(tinyOut)
8078
+ && /버전 확인과 로그인 상태 명령을 실행/.test(RS(['agents', 'route', '--log', '--path', d]).o);
8079
+ // N3: 규칙이 못 읽는 문자 체계가 섞이면 tiny 로 내리지 않는다(결제·인증이 tiny 로 확정되던 구멍)
8080
+ const RTm = require(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'));
8081
+ N3 = RTm.classify('支付退款逻辑修改 css').tier === 'normal'
8082
+ && RTm.classify('決済リファンド css 修正').tier === 'normal'
8083
+ && RTm.classify('fix typo in css').tier === 'tiny' // 대조군: 커버되는 언어는 그대로 tiny
8084
+ // 전각 문자로 고위험 규칙을 우회할 수 있었다(PAYMENT → normal) — 매칭 전 NFKC 정규화
8085
+ && RTm.classify('PAYMENT refund logic').tier === 'high-risk';
8086
+ // N4: bugfix start 가 위치 경로를 인식한다(cwd 오염 없이 대상 프로젝트에 등록)
8087
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h2b-')); _d.push(d2);
8088
+ RS(['init', d2, '--yes', '--language', 'ko', '--no-stale-check']);
8089
+ const aid = ((RS(['task', 'add', '대상 작업', '--path', d2, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
8090
+ fs.writeFileSync(path.join(d2, 'app.js'), 'console.error("BUG_HERE"); process.exit(3);');
8091
+ const stRes = RS(['bugfix', 'start', aid, '--repro', 'node app.js', '--expect-bad', 'BUG_HERE', d2]);
8092
+ N4 = stRes.s === 0 && fs.existsSync(path.join(d2, '.harness', 'bugfix-receipts.json'));
8093
+ // N5: Bearer/Basic 토큰 마스킹 + 손상 토글 무경고 금지
8094
+ const red = require(path.join(path.dirname(CLI), '..', 'lib', 'pure-utils.js')).redactSecrets;
8095
+ const maskOk = !red('Authorization: Bearer sk-live-ABCDEFGH12345', 200).includes('sk-live-ABCDEFGH12345')
8096
+ && !red('curl -H "Authorization: Basic dXNlcjpwYXNz" api', 200).includes('dXNlcjpwYXNz');
8097
+ fs.writeFileSync(path.join(d, '.harness', 'toggles.json'), '{손상');
8098
+ const tl = RS(['toggle', 'list', '--path', d]);
8099
+ let tj = null; try { const o = RS(['toggle', 'list', '--path', d, '--json']).o; tj = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
8100
+ // 감사 기록은 **입력 원문**(분류용 정규화본이 아님)을 남기되, git 추적 파일이므로 시크릿은 가린다
8101
+ const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h2c-')); _d.push(d3);
8102
+ RS(['init', d3, '--yes', '--language', 'ko', '--no-stale-check']);
8103
+ RS(['agents', 'route', 'PAYMENT 수정 API_TOKEN=sk-live-SECRET123', '--path', d3]);
8104
+ const lgRaw = fs.readFileSync(path.join(d3, '.harness', 'routing-log.json'), 'utf8');
8105
+ const auditOk = !/sk-live-SECRET123/.test(lgRaw) && /API_TOKEN=\*\*\*/.test(lgRaw) && /PAYMENT/.test(lgRaw);
8106
+ N5 = maskOk && auditOk && /toggles\.json 손상/.test(tl.o) && !!tj && tj.corrupt === true;
8107
+ ok = N1 && N2 && N3 && N4 && N5;
8108
+ if (!ok) console.log(` [헌트5 디버그] 제안무실행=${N1}(제안 ${suggestCalls}/${suggestJsonCalls} · 확정 ${confirmCalls} · optout ${optOutCalls}) 사유구분=${N2} 다국어=${N3} 위치경로=${N4} 마스킹+손상고지=${N5}`);
8109
+ } catch (e) { console.log(' [헌트5 디버그] 예외: ' + ((e && e.message) || e)); }
8110
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8111
+ console.log(ok ? '✓ B(1.36.92) 신규표면 헌트 5건: 제안은 외부 프로세스 0회(PATH shim 증명)·확정에서만 실행 · 인증 미확인 사유 구분 · 미커버 문자체계는 tiny 금지 · bugfix 위치경로 인식 · Bearer 마스킹 + 손상 토글 경고' : '✗ 신규표면 헌트 5건 실패');
8112
+ if (!ok) failed++;
8113
+ }
8114
+
8115
+ // 1.36.93 (헌트 이월 2건): ① MCP 래퍼 타임아웃이 probe 예산보다 짧아 정직하게 고친 사용자가 done 을 못 하고,
8116
+ // 응답이 `(no output)` 이라 진단 단서가 0 이었다 ② `plan add --status verified` 가 정규화 없이 저장돼
8117
+ // lazy detect 는 done 으로 세는데 verify-claim/gate 는 그 행을 아예 보지 않았다(검증기가 못 보는 완료 행).
8118
+ total++;
8119
+ {
8120
+ let ok = false;
8121
+ const _d = [];
8122
+ const R = (args, env) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 300000, env: env || process.env }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
8123
+ let T1 = false, T2 = false, T3 = false;
8124
+ try {
8125
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-carry-')); _d.push(d);
8126
+ R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
8127
+ // T1: plan add 의 상태도 task add 와 **같은 정규화**를 탄다 — 저장 형태가 갈리면 집계마다 다르게 센다
8128
+ R(['task', 'add', 'A', '--path', d, '--status', 'completed', '--json', '--no-review']);
8129
+ R(['plan', 'add', 'B', '--path', d, '--status', 'completed', '--json']);
8130
+ R(['plan', 'add', 'C', '--path', d, '--status', 'verified', '--json']);
8131
+ const rows = fs.readFileSync(path.join(d, '.harness', 'progress-tracker.md'), 'utf8')
8132
+ .split('\n').filter(l => /^\|\s*T-\d/.test(l)).map(l => l.split('|').map(s => s.trim())[2]);
8133
+ const doneCount = rows.filter(s => s === 'done').length;
8134
+ let vcTotal = null; try { const o = R(['verify-claim', '--all', '--path', d, '--json']).o; vcTotal = JSON.parse(o.slice(o.indexOf('{'))).total; } catch {}
8135
+ // 판별: 정규화 안 된 행이 0 이고, **검증기가 보는 수 = done 행 수** 여야 한다(전엔 2 vs 3 으로 갈렸다)
8136
+ T1 = rows.filter(s => s === 'completed' || s === 'verified').length === 0 && doneCount === 3 && vcTotal === doneCount;
8137
+ // T2/T3: MCP 래퍼 타임아웃 — 사유를 사실대로 전달하고, 기본 한도는 probe 예산(600s) 위여야 한다
8138
+ const mcp = (ms) => {
8139
+ const msgs = [JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: {} }),
8140
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_selftest', arguments: { path: d } } })].join('\n') + '\n';
8141
+ const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--path', d], {
8142
+ input: msgs, encoding: 'utf8', timeout: 200000,
8143
+ env: Object.assign({}, process.env, ms ? { LEERNESS_MCP_TIMEOUT_MS: String(ms) } : {}),
8144
+ });
8145
+ const lines = ((r.stdout || '')).split('\n').filter(l => l.includes('"id":1'));
8146
+ return lines[lines.length - 1] || '';
8147
+ };
8148
+ const shortRes = mcp(5000); // selftest 는 ~20초 — 5초 한도면 반드시 걸린다
8149
+ T2 = !/no output/.test(shortRes) && /초 안에 끝나지 않아 중단/.test(shortRes)
8150
+ && /LEERNESS_MCP_TIMEOUT_MS/.test(shortRes) && /터미널에서 직접/.test(shortRes)
8151
+ // codex 33차 #5: 메시지의 초 값이 **실제 적용된 한도**여야 한다 — 소스 리터럴만 보면
8152
+ // `Math.min(MCP_TIMEOUT_MS, 60000)` 같은 변조가 그대로 통과한다. 5초 지정 → "5초" 로 찍혀야 한다.
8153
+ && /⏱ 5초 안에/.test(shortRes)
8154
+ // codex 33차 #3: probe 설명은 probe 가 돌 수 있는 호출에만 — selftest 에는 붙으면 안 된다
8155
+ && !/bugfix probe 가 등록된 task/.test(shortRes);
8156
+ // codex 33차 #2: 긴 예산은 **done 전이/sync 에만** 준다 — 전역으로 올리면 단일 스레드 서버가 그만큼 얼어붙는다.
8157
+ const srcTxt = fs.readFileSync(CLI, 'utf8');
8158
+ const probeBudget = (srcTxt.match(/MCP_TIMEOUT_PROBE_MS = (\d+)/) || [])[1];
8159
+ const defBudget = (srcTxt.match(/MCP_TIMEOUT_DEFAULT_MS = (\d+)/) || [])[1];
8160
+ T3 = !!probeBudget && Number(probeBudget) > 600000 // probe 예산 위
8161
+ && !!defBudget && Number(defBudget) <= 120000 // 기본은 짧게(서버 정지 시간 제한)
8162
+ && /timeout: budget,/.test(srcTxt) // 배선: 계산된 예산이 실제로 쓰인다
8163
+ && /_needsProbeBudget\(cliArgs\)/.test(srcTxt);
8164
+ ok = T1 && T2 && T3;
8165
+ if (!ok) console.log(` [이월2 디버그] plan정규화=${T1}(done=${doneCount} vc=${vcTotal}) 타임아웃사유=${T2} 기본한도=${T3}(${srcTimeout})`);
8166
+ } catch (e) { console.log(' [이월2 디버그] 예외: ' + ((e && e.message) || e)); }
8167
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8168
+ console.log(ok ? '✓ B(1.36.93) 헌트 이월 2건: plan add 상태 정규화(검증기가 보는 수 = done 행 수) · MCP 타임아웃 사유 전달(+CLI 대안·한도조절) · 기본 한도 > probe 예산' : '✗ 헌트 이월 2건 실패');
8169
+ if (!ok) failed++;
8170
+ }
8171
+
8010
8172
  // 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
8011
8173
  total++;
8012
8174
  {