leerness 1.36.90 → 1.36.92

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,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.92 — 2026-07-30 — 동의 없는 외부 CLI 실행 차단 · 과소평가 우회 3종 · 손상 토글 fail-closed
4
+
5
+ 1.36.88~91 에서 새로 만든 표면(bugfix 게이트·라우팅·대시보드·인증 축)을 6렌즈 적대 헌트(41 에이전트)로 훑어 **21건 확인 / 14건 반증**. 이번 릴리스는 그중 상위 5건 + codex 32차 검수 8건을 반영했고, 나머지 16건은 다음 라운드로 기록해 두었다.
6
+
7
+ **동의 없이 외부 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`·거부 판정보다 **먼저** 실행된다.
8
+ → 확인은 **확정 요청 + 토글 ON + provider opt-in(`LEERNESS_ENABLE_*=1`)** 이 모두 참일 때만. 제안 단계는 프로세스 0회.
9
+
10
+ **과소평가 우회 3종** — tiny 는 "범위가 좁다"는 주장인데, 규칙이 읽지 못하는 입력에서 그 주장이 통과했다:
11
+ - **다국어**: 고위험 규칙 어휘는 영어·한국어뿐인데 tiny 어휘(css/lint/typo)는 라틴이라 살아남아, `支付退款逻辑修改 css`(중국어 결제 환불)가 **tiny** 로 확정됐다.
12
+ - **전각**: `PAYMENT refund` 이 어떤 규칙에도 안 걸려 normal 로 떨어졌다(반각은 high-risk) → 매칭 전 NFKC 정규화(실측 3500자 200회 4ms).
13
+ - **제로폭**: `pa<ZWSP>yment re<ZWSP>fund css` 로 영문 키워드를 쪼개 우회 → 서식문자 제거.
14
+ - 악센트 라틴(스페인어 등)도 미커버로 본다. **한계는 그대로 적었다**: 악센트 없는 비영어 라틴 문장은 여전히 구분하지 못한다.
15
+
16
+ **손상 토글에서 게이트가 무언 통과했다** — `bugfix-receipt` 는 defaultOff 라 손상 = OFF 다. 앞선 수정은 `toggle list` 에 경고만 달고 **강제 시점을 그대로 뒀다**(codex 32차 #1 BLOCK: "화면에 경고를 띄우는 것과 실제로 막는 것은 다른 일"). 이제 done 전이에서 fail-closed 로 보류하고, `{"bugfix-receipt":"banana"}` 처럼 **JSON 은 멀쩡한데 값을 못 읽는** 경우도 같은 취급을 한다.
17
+
18
+ **그 밖에:**
19
+ - `bugfix start|receipt|drop` 이 위치 경로를 **조용히 무시**했다(`list` 만 인식) — 지목한 프로젝트는 무보호로 done 되고 cwd 의 같은 번호 task 가 갇혔다. 경로형 토큰을 인식하고(UNC 포함), 위치 경로와 `--path` 를 **둘 다** 주면 거부한다.
20
+ - 시크릿 마스킹: `Authorization: Bearer <토큰>` 이 통과하면서 옆에 `***` 가 찍혀 **가려졌다는 거짓 안심**을 줬다(안 가리는 것보다 나쁘다). 짧은 토큰(`Basic YTpi`)과 `~` 포함 token68 을 잡으려 규칙을 넓히면 산문(`Basic authentication flow`)이 훼손되므로, **authorization 헤더 문맥에 앵커**해 값 전체를 지운다 — 우회 7종 차단 · 오탐 0건 실측.
21
+ - 감사 기록은 **입력 원문**을 남긴다(분류용 정규화본이 아니라). 단 git 추적 파일이므로 시크릿 값만 가린다.
22
+ - 인증 미확인 사유를 구분한다 — "이 등급에서는 확인하지 않음" 과 "확인 수단이 등록되지 않음" 은 다른 말인데, 유일하게 확인 명령이 있는 codex 를 "수단 없는 provider" 로 읽게 만들고 있었다.
23
+ - `_EXEC_NOTE` 의 "상태 명령만 실행" 도 부정확했다 — `--version` 도 함께 돈다. 실행 시점과 대상을 정확히 적었다.
24
+ - `--approved-by` 의 제로폭 문자 제거 — 보이지 않는 승인자가 감사 기록에 남지 않게.
25
+
26
+ **가드**: PATH shim 으로 "프로세스를 띄우지 않는다"를 증명한다 — 종전 테스트에는 그걸 증명할 수단이 **아예 없었다**. 제안 0회 / 확정 N회 / opt-out OFF 0회의 3방향 판별을 단언하고, shim 은 배치되는 provider 전부(codex·claude)에 건다. 라우팅 변이 14/14 · 인증축 변이 9/10(1건은 등가 변이, 대조 변이 M9·M10 로 확인).
27
+
28
+ **작업 중 내 대조군이 내 수정을 잡았다**: 정규식 **리터럴**에 백슬래시를 두 개 넣어 `/[\\u0370-\\u03ff]/` 가 되면서 문자 클래스가 `\`·`u`·숫자·`f` 를 뜻하게 됐고, `fix typo in css` 의 `f` 가 "그리스 문자"로 잡혀 tiny 가 normal 로 떨어졌다. 대조군을 함께 돌리지 않았다면 "다국어 방어 강화"라고 보고하면서 멀쩡한 영어 입력을 망가뜨린 채 출하했을 것이다.
29
+
30
+ 게이트: exit 0 · E2E 412/412 · selftest 341/341 · ✗ 0건
31
+
32
+ ## 1.36.91 — 2026-07-30 — 인증 축: **설치됨 ≠ 사용가능**을 고지에서 실제 판정으로
33
+
34
+ 1.36.89 의 라우팅은 "설치·활성만 확인했고 인증·쿼터·과금 권한은 확인하지 않았다"를 **말하기만** 했다. 이번엔 확인할 수 있는 것을 실제로 확인하고, 확인할 수 없는 것은 그대로 남긴다.
35
+
36
+ **무엇을 확인할 수 있는지 먼저 실측했다.** 후보 CLI 11종의 `--help` 를 읽어 인증 관련 하위명령을 찾고, 확실히 무료인 것만 실행했다:
37
+
38
+ - `codex login status` → exit 0 · **158ms** · "Logged in using ChatGPT" → **등록**
39
+ - `claude auth` → status 하위명령 없음(exit 1) → unknown 유지
40
+ - `ollama list` → **8초 타임아웃, Ollama 앱을 기동시킨다**(부작용) → **등록 제외**
41
+ - 나머지 7종 → 무료·비대화형 확인 수단 없음 → unknown 유지
42
+
43
+ 등록 기준을 코드에 못 박았다 — ① 모델을 호출하지 않아 과금이 없다 ② stdin 없이 끝난다 ③ 서비스/앱을 기동하지 않는다. 셋 다 만족하지 못하면 **추정하지 않고 unknown 으로 남긴다**. 지금 10종 중 1종만 판정되는 것이 정직한 현실이다.
44
+
45
+ - **`agents check`** 가 인증 축을 별도로 보고한다(`auth: ok | no | unknown` · `authVerified` · `authUnknown`). `status`(설치+활성)의 의미는 그대로 두었다 — 두 검증을 한 단어에 섞으면 무엇이 확인됐는지 알 수 없어진다.
46
+ - **`agents list` 는 확인하지 않는다** — 목록 조회마다 외부 프로세스를 띄우면 안 된다.
47
+ - **라우팅 연동의 비대칭**: `unknown` 은 막지 않고 **확실한 `no` 만** 고위험에서 차단한다. unknown 을 차단 사유로 쓰면 확인 수단이 없는 대부분의 CLI 에서 고위험 작업이 전부 잠긴다(false-BLOCK).
48
+
49
+ **codex 30차 검수(BLOCK 1 포함 7건)를 전부 반영했다:**
50
+
51
+ - **라우팅이 다른 실행파일의 인증을 보고할 수 있었다** — `EXTERNAL_AGENTS` 를 직접 봐서 프로젝트의 provider 재정의(`_allProviders`)를 무시했다. `agents check` 는 재정의된 CLI 를, 라우팅은 전역 동명 CLI 를 확인하는 불일치. 같은 해석기를 쓰도록 고쳤다.
52
+ - **모든 nonzero 를 "확실히 미인증"으로 읽었다** — 구버전(하위명령 없음)·설정 오류·내부 크래시도 nonzero 다. `no` 는 고위험을 차단하므로 이건 멀쩡한 사용자를 잠근다. 이제 **로그아웃으로 알아볼 수 있는 문구**(`noPattern`)가 함께 잡힐 때만 `no` 이고, 아니면 unknown 이다. ⚠ 로그아웃 상태의 실제 codex 출력은 관측하지 못했다(로그인 상태였다) — 패턴이 빗나가면 unknown 이 되어 **안전한 방향으로** 실패한다.
53
+ - **라우팅이 "외부 CLI 를 실행하지 않는다"고 했는데 거짓이 됐다** — 인증 확인이 실제로 CLI 를 실행한다. 문구를 사실에 맞추고(모델 호출은 하지 않음 · 고위험에서 상태 명령만 실행), 확인 자체를 **고위험 등급에서만** 하도록 좁혔다(tiny/normal 은 지연도 실행도 없다).
54
+ - **evidence 가 하위 프로세스 출력을 그대로 재발행했다** — 토큰 마스킹 + 제어문자(터미널 이스케이프) 제거. 마스킹 구현은 `lib/pure-utils.js` 로 올려 대시보드와 **같은 함수**를 쓴다(표면마다 다시 구현하면 한쪽만 고쳐진다).
55
+ - 셸 메타문자가 든 `authCheck.args` 는 실행 자체를 거부한다(인용이 깨져 엉뚱한 실행이 되고 주입 표면이 된다).
56
+ - e2e 의 "확인 명령 없는 provider 는 unknown" 단언이 **provider 누락도 성공으로 읽었다**(`!a || …`) → 존재를 먼저 요구.
57
+ - "무료·비대화형 확인 명령 없음"은 과장이었다(없다고 증명한 게 아니라 **등록된 것이 없을** 뿐) → 문구 정정.
58
+
59
+ **가드**: 변이 9종 중 8종 검출 + 1종은 **등가 변이**로 확인했다(정상 등급에서는 인증을 계산하지 않으므로 차단 조건을 바꿔도 동작 차이가 없다 — 같은 지점을 실제로 깨는 대조 변이 M9 는 잡힌다). "인증 확인을 통째로 끄는" 변이가 처음에 살아남았는데, 확인이 **실제로 수행되는지**를 아무도 단언하지 않았기 때문이다 — 종료코드를 테스트가 정하는 합성 provider 로 메커니즘 자체를 검사하게 고쳤다(실제 CLI 설치 여부에 의존하는 단언은 그 CLI 가 없는 머신에서 공허해진다).
60
+
61
+ **작업 중 자체 가드가 나를 두 번 잡았다**: ① 제어문자 제거용 정규식에 **생 NUL 을 그대로 넣어** 1.36.81 의 "출하 소스 생 NUL 금지" 메타가드가 selftest 를 실패시켰다 ② 1.36.89 의 route 가드가 옛 정직성 문구를 단언하고 있어, 문구가 거짓이 되어 고치자 실패했다 — 가드를 새 주장에 맞추면서 **옛 과장 문구가 되살아나지 않는지**도 함께 단언하도록 보강했다.
62
+
63
+ 게이트: exit 0 · E2E 411/411 · selftest 341/341 · ✗ 0건
64
+
3
65
  ## 1.36.90 — 2026-07-29 — 하네스 대시보드 (P-0006, 읽기 전용) · 승인 시 정한 경계를 테스트로 못 박음
4
66
 
5
67
  사용자 요청은 더 컸다 — "`leerness.html` 을 없애고 `.harness` 안에 node/python 코드를 두고, 루트에 실행기를 만들어 서버로 게이트를 활성화하고 미리보기도 서버에서 본다." codex 와 함께 검토해 **대시보드만** 만들기로 정했고(사용자 승인), 넘지 않을 경계를 승인 시점에 preview 스토어에 기록했다.
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.90 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.92 하네스를 사용합니다. 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.90는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.92는 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.90는 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.90 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.92 릴리스 태그 이력** (수백 라운드) · _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.90: 2026-07-29
238
+ Last synced by Leerness v1.36.92: 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.90';
37
+ const VERSION = '1.36.92';
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), () => {
@@ -15328,9 +15344,50 @@ function _checkAgent(agent, opts = {}) {
15328
15344
  error = `exit ${r.status}`;
15329
15345
  }
15330
15346
  } catch (e) { error = e.message; }
15347
+ // 1.36.91: **설치됨 ≠ 사용가능**. 위 확인은 바이너리가 뜨는지만 본다 — 인증·모델 사용권한·쿼터·과금 권한은
15348
+ // 아무것도 증명하지 않는다. 무료·비대화형·부작용 없음이 확인된 authCheck 를 가진 provider 만 실제로 판정하고,
15349
+ // 나머지는 'unknown' 으로 **남긴다**(모르는 것을 안다고 말하지 않는다). opts.auth 일 때만 실행 — 지연 비용이 있다.
15350
+ let auth = 'unknown', authEvidence = null, authSource = null;
15351
+ // args 는 shell:true 로 실행되므로(윈도우 .cmd 심 해석에 필요) **셸 메타문자가 없어야** 한다.
15352
+ // 있으면 인용이 깨져 엉뚱한 실행이 되고(실측: 따옴표 포함 args 가 파싱 실패로 exit 1), 주입 표면도 된다.
15353
+ const _argsSafe = (a) => Array.isArray(a) && a.length > 0 && a.every(x => typeof x === 'string' && /^[A-Za-z0-9._:@/\\-]+$/.test(x));
15354
+ if (installed && opts.auth && agent.authCheck && _argsSafe(agent.authCheck.args)) {
15355
+ authSource = `${agent.bin} ${agent.authCheck.args.join(' ')}`;
15356
+ try {
15357
+ const ar = cp.spawnSync(agent.bin, agent.authCheck.args, {
15358
+ encoding: 'utf8', timeout: agent.authCheck.timeoutMs || 8000, shell: true, stdio: ['ignore', 'pipe', 'pipe'],
15359
+ });
15360
+ const aout = ((ar.stdout || '') + (ar.stderr || '')).trim();
15361
+ // **"실행하지 못함"을 "확실히 미인증"으로 둔갑시키지 않는다.** `no` 는 고위험 작업을 차단하므로,
15362
+ // 런처/파싱 수준 실패를 no 로 읽으면 멀쩡한 사용자가 부당하게 잠긴다(bugfix probe 와 같은 규율).
15363
+ // 스택 프레임(`\n at file:line:col`)은 확인 명령 자체가 던진 것이다 — CLI 가 내는 "not logged in"
15364
+ // 메시지와 형태가 명확히 다르므로 이것으로 가른다(실측: 크래시하는 확인 명령이 `no` 로 읽혔다).
15365
+ const launcherish = /(?:not recognized as an internal or external command|command not found|cannot find module|\[eval\]|ENOENT)/i.test(aout)
15366
+ || /\n\s+at .+:\d+:\d+/.test(aout)
15367
+ || ar.status === 127 || ar.status === 9009;
15368
+ // evidence 는 사람이 보는 출력·JSON·CI 로그에 실린다 — 토큰 마스킹 + 제어문자(터미널 이스케이프) 제거.
15369
+ const _clean = (s) => require('../lib/pure-utils').redactSecrets(String(s).replace(new RegExp('[\\u0000-\\u001f\\u007f]', 'g'), ' '), 80).trim();
15370
+ if (ar.error || ar.status == null) { auth = 'unknown'; authEvidence = `확인 실패(${(ar.error && ar.error.code) || 'timeout'})`; }
15371
+ else if (launcherish) { auth = 'unknown'; authEvidence = `확인 명령을 실행하지 못함(exit ${ar.status})`; }
15372
+ else if (ar.status === 0) { auth = 'ok'; authEvidence = _clean(aout.split('\n')[0]) || 'exit 0'; }
15373
+ else {
15374
+ // **nonzero 를 곧바로 "확실히 미인증"으로 읽지 않는다.** 구버전(하위명령 없음)·설정 오류·내부 오류도
15375
+ // nonzero 다. `no` 는 고위험을 차단하므로, 로그아웃으로 **알아볼 수 있는 문구**가 함께 있을 때만 no.
15376
+ // 아니면 unknown — 차단하지 않는 쪽으로 실패한다(false-BLOCK 방지).
15377
+ let looksLoggedOut = false;
15378
+ try { looksLoggedOut = !!(agent.authCheck.noPattern && new RegExp(agent.authCheck.noPattern, 'i').test(aout)); } catch {}
15379
+ auth = looksLoggedOut ? 'no' : 'unknown';
15380
+ authEvidence = looksLoggedOut ? (_clean(aout.split('\n')[0]) || `exit ${ar.status}`)
15381
+ : `판정 불가(exit ${ar.status}) — 로그아웃으로 알아볼 문구 없음`;
15382
+ }
15383
+ } catch (e) { auth = 'unknown'; authEvidence = `확인 실패(${e.message})`; }
15384
+ }
15331
15385
  return {
15332
15386
  id: agent.id, bin: agent.bin, desc: agent.desc, envFlag: agent.envFlag,
15333
15387
  enabled, installed, version, error,
15388
+ auth, authEvidence, authSource,
15389
+ // 인증까지 확인된 경우에만 'ready' 라고 부르지 않는다 — 기존 status 의미(설치+활성)는 그대로 두고,
15390
+ // 인증은 별도 축으로 노출한다. 두 축을 한 단어에 섞으면 무엇이 검증됐는지 알 수 없어진다.
15334
15391
  status: enabled && installed ? 'ready' : !installed ? 'not-installed' : !enabled ? 'disabled' : 'unknown'
15335
15392
  };
15336
15393
  }
@@ -24057,8 +24114,25 @@ async function main() {
24057
24114
  if (_rtRaw[i].startsWith('--')) { if (['--tier', '--approved-by', '--path'].includes(_rtRaw[i]) && _rtRaw[i + 1] && !_rtRaw[i + 1].startsWith('--')) i++; continue; }
24058
24115
  _rtToks.push(_rtRaw[i]);
24059
24116
  }
24117
+ // _providerAuth (1.36.91): 인증 축을 라우팅이 소비한다 — 확인 가능한 provider 만 ok/no, 나머지는 unknown.
24118
+ // 확인 명령이 없는 provider 는 프로세스를 띄우지 않는다(불필요한 지연 방지).
24119
+ // codex 30차 #1: 종전엔 EXTERNAL_AGENTS 만 봐서 **프로젝트의 provider 재정의(.harness)를 무시**했다 —
24120
+ // `agents check` 는 재정의된 CLI 를, 라우팅은 전역 동명 CLI 를 확인하는 불일치가 생긴다. 같은 해석기를 쓴다.
24121
+ const _authRoot = arg('--path', process.cwd());
24122
+ const _authCache = new Map();
24123
+ const _providerAuth = (pid) => {
24124
+ if (_authCache.has(pid)) return _authCache.get(pid);
24125
+ const def = (_allProviders(_authRoot) || []).find(a => a.id === pid);
24126
+ // 1.36.92 (codex 32차 #2): 문서화된 opt-out 을 실제로 지킨다 — `LEERNESS_ENABLE_<X>=1` 이 아닌 provider 는
24127
+ // 프로세스를 띄우지 않는다. 종전엔 `enabled` 를 계산만 하고 확인 실행 전에 보지 않아, 외부 도구를 꺼 뒀다고
24128
+ // 믿는 사용자의 환경에서 CLI 가 실행됐다(실측: 플래그 유무와 무관하게 동일 spawn).
24129
+ const _enabled = !!(def && def.envFlag && process.env[def.envFlag] === '1');
24130
+ const v = (def && def.authCheck && _enabled) ? (_checkAgent(def, { auth: true }).auth || 'unknown') : 'unknown';
24131
+ _authCache.set(pid, v);
24132
+ return v;
24133
+ };
24060
24134
  return require('../lib/routing').routeCmd(arg('--path', process.cwd()), _rtToks.join(' '),
24061
- { has, arg, _withLock, _resolveRole, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
24135
+ { has, arg, _withLock, _resolveRole, _providerAuth, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
24062
24136
  }
24063
24137
  if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
24064
24138
  // 1.9.157: Provider Registry — 사용자 정의 provider 동적 추가
@@ -24216,9 +24290,27 @@ async function main() {
24216
24290
  // 등록된 probe 를 "없음"으로 보고했다 — 다른 명령들과 같은 규율로 위치 경로를 받는다.
24217
24291
  // (start/receipt/drop 의 첫 토큰은 task id 이므로, 경로로 오인하지 않도록 list/무인자에서만 적용)
24218
24292
  const _bfSub = _bfToks[0];
24219
- const _bfRest = _bfToks.slice(1);
24293
+ let _bfRest = _bfToks.slice(1);
24220
24294
  let _bfRoot = arg('--path', null);
24221
24295
  if (!_bfRoot && (!_bfSub || _bfSub === 'list')) _bfRoot = _taskPositionalPath(['bugfix', ...(_bfSub ? [_bfSub] : []), ..._bfRest], _bfSub ? 2 : 1);
24296
+ // 1.36.92 (헌트 #9, High): start/receipt/drop 은 위치 경로를 **조용히 무시**했다 — `bugfix start T-0002 <경로>` 가
24297
+ // 지목한 프로젝트가 아니라 cwd 프로젝트에 probe 를 등록해, 지목한 쪽은 무보호로 done 되고 cwd 의 같은 번호
24298
+ // task 는 영문도 모른 채 갇혔다(실측). id 뒤의 경로형 토큰을 인식하고, 인식할 수 없으면 **거부**한다.
24299
+ if (_bfSub === 'start' || _bfSub === 'receipt' || _bfSub === 'drop') {
24300
+ // UNC(\\server\share)도 경로다. 판별 못 하는 토큰은 **거부**한다 — 조용히 버리면 엉뚱한 프로젝트에 쓴다.
24301
+ const _pathLike = (s) => typeof s === 'string' && /^([A-Za-z]:[\\/]|\\\\|\/|\.\.?[\\/])/.test(s);
24302
+ const _stray = _bfRest.slice(1).filter(Boolean);
24303
+ if (_stray.length) {
24304
+ const _p = _stray.find(_pathLike);
24305
+ // codex 32차 #5: 위치 경로와 --path 를 **둘 다** 주면 종전엔 위치 경로를 조용히 버렸다 —
24306
+ // 어느 프로젝트에 쓸지 사용자 의도가 갈리는데 한쪽을 말없이 무시하면 안 된다. 거부한다.
24307
+ if (_p && _stray.length === 1 && arg('--path', null)) {
24308
+ fail(`경로가 두 번 지정됐습니다: 위치 인자 '${_p}' 와 --path '${arg('--path', null)}' — 하나만 쓰세요`); process.exitCode = 1; return;
24309
+ }
24310
+ if (_p && _stray.length === 1) { _bfRoot = absRoot(_p); _bfRest = [_bfRest[0]]; }
24311
+ else { fail(`알 수 없는 인자: ${_stray.join(' ')} — 경로는 마지막에 하나만 두거나 --path <경로> 를 쓰세요 (예: leerness bugfix ${_bfSub} ${_bfRest[0] || '<T-ID>'} --path .)`); process.exitCode = 1; return; }
24312
+ }
24313
+ }
24222
24314
  // _withLock: 동시 등록이 서로를 지우면 게이트가 조용히 꺼진다(실측 4→2)
24223
24315
  // _taskExists: 존재하지 않는 id 로 등록되면 오타 하나로 "보호받는 줄 아는" 무보호 상태가 된다(출하전 헌트 #3)
24224
24316
  return require('../lib/bugfix').bugfixCmd(_bfRoot || process.cwd(), _bfSub, _bfRest,
@@ -24540,6 +24632,9 @@ module.exports = {
24540
24632
  _parseSlashFromHelp, _probeAgentSlash, _refreshAgentSlashCommands,
24541
24633
  // 1.9.269: 시스템(OS) 언어 감지 (UR-0022) + 언어 판별 — 단위 테스트
24542
24634
  _detectSystemLang, detectLanguageValue,
24635
+ // 1.36.91: 인증 축(설치됨≠사용가능) — authCheck 실행 여부를 **결정적으로** 검증하려면 함수 단위 접근이 필요하다
24636
+ // (실제 CLI 설치 여부에 의존하는 단언은 그 CLI 가 없는 머신에서 공허해진다).
24637
+ _checkAgent,
24543
24638
  // 1.9.270: agent roles — 모델별 역할 부여 (사용자 명시) — 단위 테스트
24544
24639
  ROLE_CATALOG, _normalizeRole, _pickModel, _rolesFile, _loadRoles, _saveRoles, _resolveRole, _suggestRoles, rolesCmd, _dispatchCommand,
24545
24640
  // 1.9.272: 권한/보안 표면 공개 (GPT-5.5 리뷰) — 단위 테스트
@@ -9,7 +9,18 @@
9
9
  const EXTERNAL_AGENTS = [
10
10
  { id: 'claude', bin: 'claude', envFlag: 'LEERNESS_ENABLE_CLAUDE', versionArgs: ['--version'], desc: 'Anthropic Claude Code CLI',
11
11
  installCmd: 'npm i -g @anthropic-ai/claude-code', installHint: 'https://docs.anthropic.com/en/docs/claude-code/setup' },
12
+ // 1.36.91: authCheck — **무료·비대화형·부작용 없음**이 실측으로 확인된 명령만 등록한다.
13
+ // `--version` 이 도는 것은 인증·모델 사용권한·쿼터·과금 권한을 아무것도 증명하지 않는다.
14
+ // 등록 기준(셋 다 만족해야 함): ① 모델을 호출하지 않아 과금이 없다 ② stdin 없이 끝난다
15
+ // ③ 서비스/앱을 기동하는 부작용이 없다. 확인 못 한 provider 는 **'unknown' 으로 남긴다** — 추정 금지.
16
+ // 실측 제외: `ollama list` 는 Ollama 앱을 기동시켜 8초 타임아웃(부작용 있음) · `claude auth` 는 status 하위명령 없음(exit 1).
17
+ // `no`(확실히 미인증)는 고위험 작업을 **차단**하므로 종료코드만으로 단정하지 않는다 —
18
+ // 구버전이라 하위명령이 없거나, 설정 파일을 못 읽거나, 내부 오류여도 nonzero 가 나온다.
19
+ // 그래서 `noPattern` 에 **로그아웃 상태로 알아볼 수 있는 문구**가 함께 잡힐 때만 no 로 본다.
20
+ // ⚠ 로그아웃 상태의 실제 출력은 이 머신에서 관측하지 못했다(로그인 상태였다) — 패턴이 빗나가면
21
+ // no 대신 unknown 이 되고, unknown 은 차단하지 않으므로 안전한 방향으로 실패한다.
12
22
  { id: 'codex', bin: 'codex', envFlag: 'LEERNESS_ENABLE_CODEX', versionArgs: ['--version'], desc: 'OpenAI Codex CLI (격리 sandbox)',
23
+ authCheck: { args: ['login', 'status'], timeoutMs: 8000, noPattern: 'not logged in|logged out|no credentials|run .*login|sign in', note: 'codex login status (실측 158ms · 모델 호출 없음)' },
13
24
  installCmd: 'npm i -g @openai/codex', installHint: 'https://github.com/openai/codex' },
14
25
  // 1.9.248 (사용자 명시 UR-0017): Gemini CLI 제거 + Antigravity CLI (agy 명령어) 교체
15
26
  // Google Antigravity 는 IDE 기반 멀티 모달 에이전트 도구. agy CLI 는 워크스페이스 직접 수정 가능 모드 지원.
package/lib/agents.js CHANGED
@@ -75,11 +75,30 @@ function agentsCmd(root, sub, args = [], deps = {}) {
75
75
  if (sub === 'check') {
76
76
  // list의 alias, 단 명시적 재확인 (JSON 출력 기본)
77
77
  // 1.9.157: Provider Registry 통합
78
+ // 1.36.91: `check` 는 **명시적 재확인** 명령이므로 인증 확인까지 수행한다(`list` 는 지연 때문에 하지 않는다).
79
+ // 확인 가능한 provider 만 ok/no 로 판정하고 나머지는 unknown 으로 남긴다 — 설치됨을 사용가능이라 부르지 않는다.
78
80
  const providers = _allProviders(root);
79
81
  const userIds = new Set(_readUserProviders(root).map(u => u.id));
80
- const checks = providers.map(a => ({ ...(_checkAgent(a)), source: userIds.has(a.id) ? 'user' : 'builtin' }));
81
- if (has('--json')) { log(JSON.stringify({ agents: checks, ready: checks.filter(c => c.status === 'ready').map(c => c.id) }, null, 2)); return; }
82
- return agentsCmd(root, 'list', [], deps); // 비-JSON은 list와 동일
82
+ const checks = providers.map(a => ({ ...(_checkAgent(a, { auth: true })), source: userIds.has(a.id) ? 'user' : 'builtin' }));
83
+ const authNote = '설치·활성은 확인했습니다. 인증은 무료·비대화형 확인 명령이 있는 provider 만 판정했고(auth: ok|no), 나머지는 unknown 입니다 쿼터·과금 권한은 어느 provider 확인하지 않았습니다.';
84
+ if (has('--json')) {
85
+ log(JSON.stringify({
86
+ agents: checks, ready: checks.filter(c => c.status === 'ready').map(c => c.id),
87
+ authVerified: checks.filter(c => c.auth === 'ok').map(c => c.id),
88
+ authUnknown: checks.filter(c => c.auth === 'unknown').map(c => c.id),
89
+ authNote,
90
+ }, null, 2));
91
+ return;
92
+ }
93
+ agentsCmd(root, 'list', [], deps); // 비-JSON은 list 표 + 아래 인증 축
94
+ log(`\n## 인증 확인 (1.36.91)`);
95
+ checks.filter(c => c.installed).forEach(c => {
96
+ const mark = c.auth === 'ok' ? '🟢 확인됨' : c.auth === 'no' ? '🔴 미인증' : '⚪ 확인안됨';
97
+ // codex 30차 #7: "확인 명령 없음"은 과장이다 — 없다고 증명한 게 아니라 **등록된 것이 없을** 뿐이다.
98
+ log(` ${mark} ${c.id.padEnd(10)} ${c.authSource ? `(${c.authSource})` : '(등록된 확인 명령 없음 — 무료·비대화형·부작용 없음이 확인된 것만 등록합니다)'}${c.authEvidence ? ' — ' + c.authEvidence : ''}`);
99
+ });
100
+ log(` ⓘ ${authNote}`);
101
+ return;
83
102
  }
84
103
 
85
104
  // 1.9.152: agents multi — 1.9.151 install 복수 선택된 ready 에이전트들에 일괄 dispatch 명령 생성
package/lib/dashboard.js CHANGED
@@ -55,13 +55,9 @@ function _asList(res, label, notes) {
55
55
 
56
56
  // 시크릿 마스킹 — probe 명령·작업 제목 같은 자유 텍스트에 토큰이 섞여 들어올 수 있다.
57
57
  // 화면과 /api/state 는 사람이 보는 표면이므로, 값 자체를 내보내지 않는다(키 이름은 남겨 맥락 유지).
58
- const _SECRET_ASSIGN = /\b([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*[=:]\s*("[^"]*"|'[^']*'|\S+)/gi;
59
- 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;
60
- function _redact(s, max) {
61
- let out = String(s == null ? '' : s).replace(_SECRET_ASSIGN, (m, k) => `${k}=***`).replace(_SECRET_SHAPE, '***');
62
- if (max && out.length > max) out = out.slice(0, max) + '…';
63
- return out;
64
- }
58
+ // 1.36.91: 구현을 lib/pure-utils.js 로 올렸다 — 인증 확인 evidence 도 같은 마스킹이 필요해졌고,
59
+ // 표면마다 다시 구현하면 한쪽만 고쳐지는 불일치가 생긴다(1.36.90 에서 이미 겪은 클래스).
60
+ const _redact = require('./pure-utils').redactSecrets;
65
61
 
66
62
  // 스냅샷 — **읽기 전용**. 원본 파일을 그대로 뿌리지 않고 표시할 필드만 골라 담는다
67
63
  // (원문 덤프는 시크릿·개인정보가 섞여 나갈 수 있는 표면을 만든다).
package/lib/pure-utils.js CHANGED
@@ -9,6 +9,30 @@ function _isSecretKey(k) {
9
9
  return /TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE/i.test(k);
10
10
  }
11
11
 
12
+ // 표시용 자유 텍스트에서 시크릿 값을 가린다 — 키 이름은 남겨 맥락을 유지하고 **값만** 지운다.
13
+ // 1.36.91: 대시보드와 인증 확인 evidence 두 곳이 필요해 여기(순수 함수)로 올렸다.
14
+ // 표면마다 다시 구현하면 한쪽만 고쳐지는 불일치가 생긴다(1.36.90 에서 이미 겪은 클래스).
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
+ // 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;
27
+ function redactSecrets(s, max) {
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, '***');
32
+ if (max && out.length > max) out = out.slice(0, max) + '…';
33
+ return out;
34
+ }
35
+
12
36
  // semver 비교: a>b → 1, a<b → -1, 같음 → 0. (누락 파트/null 안전)
13
37
  function compareVer(a, b) {
14
38
  const A = String(a || '0'), B = String(b || '0');
@@ -1291,7 +1315,7 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1291
1315
  module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
1292
1316
  _parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
1293
1317
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
1294
- _isSecretKey, compareVer, parseHarnessVersion,
1318
+ _isSecretKey, redactSecrets, compareVer, parseHarnessVersion,
1295
1319
  _isPlaceholderSecret, _looksSecretLike,
1296
1320
  _mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue,
1297
1321
  _parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine,
package/lib/routing.js CHANGED
@@ -53,14 +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
- const _AVAIL_NOTE = '이 명령은 역할 매핑(leerness roles)읽습니다 해당 CLI 설치·인증·모델 사용권한·쿼터·과금 권한은 확인하지 않았습니다(확인: leerness agents check).';
93
+ // 1.36.91: 인증 축이 생겼다 확인 가능한 provider 판정하고 나머지는 '확인안됨' 이다. 쿼터·과금 권한은 여전히 아무도 확인하지 않는다.
94
+ const _AVAIL_NOTE = '인증 확인은 **--confirm 으로 고위험 구성을 확정할 때만** 실행합니다(제안 단계에서는 외부 CLI 를 띄우지 않습니다). 확인하더라도 무료·비대화형 확인 명령이 등록된 provider 만 판정되며, 모델 사용권한·쿼터·과금 권한은 어느 provider 도 확인하지 않습니다(상세: leerness agents check).';
62
95
  const _CLASS_NOTE = '난이도는 텍스트 규칙 매칭 결과이며 측정값이 아닙니다.';
63
- const _EXEC_NOTE = 'leerness 여기서 외부 CLI 실행하지 않습니다계획을 확정하고 기록할 뿐입니다.';
96
+ // 1.36.91 (codex 30차 #3): 인증 축이 생기면서 문구가 **거짓이 됐다**인증 확인은 외부 CLI 를
97
+ // 실제로 실행한다(`<bin> --version` · 등록된 status 명령). "모델을 호출하지 않는다"와 "프로세스를 전혀
98
+ // 띄우지 않는다"는 다른 말이고, 전자만 참이다. 문구를 사실에 맞춘다.
99
+ // 1.36.92 (헌트 #3): "상태 명령만 실행" 도 부정확했다 — 인증 확인은 `--version` 도 함께 돌린다(감지용).
100
+ const _EXEC_NOTE = 'leerness 는 여기서 작업을 대신 수행하지 않습니다 — 계획을 확정하고 기록할 뿐이며, 모델 호출(과금)은 하지 않습니다. (고위험 구성을 --confirm 으로 확정할 때만, 인증 확인을 위해 해당 CLI 의 버전 확인과 로그인 상태 명령을 실행합니다.)';
64
101
 
65
102
  // 난이도 판정 — 순서가 곧 정책이다. (1) 사용자 명시 (2) 고위험 도메인 (3) 초소형 (4) 기본 normal
66
103
  function classify(text, opts = {}) {
@@ -107,6 +144,15 @@ function classify(text, opts = {}) {
107
144
  // tiny 는 "범위가 좁다"는 주장이다 — 광범위 신호가 있거나 설명이 길면 그 주장을 인정하지 않는다.
108
145
  const broad = BROAD.test(t);
109
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
+ }
110
156
  if (broad || longText) {
111
157
  reasons.push(`초소형 신호(${tiny.map(r => r.ko).join(', ')})가 있지만 ${broad ? '광범위 신호가 함께 있어' : `설명이 ${t.length}자로 길어(기준 ${TINY_MAX_LEN}자)`} 좁은 범위로 볼 수 없습니다 — normal 로 둡니다`);
112
158
  return { ok: true, tier: 'normal', confidence: 'low', signals, reasons, conflict: true };
@@ -142,8 +188,26 @@ function plan(root, cls, deps = {}) {
142
188
  const reviewerIndependent = !reviewer || !reviewer.resolved || !coder || !coder.resolved
143
189
  ? null
144
190
  : reviewer.provider !== coder.provider;
191
+ // 1.36.91: 인증이 **확인 결과 아님(no)** 인 provider 는 고위험에서 fail-closed.
192
+ // unknown 은 막지 않는다 — 대부분의 CLI 는 무료·비대화형 확인 수단이 없어서, unknown 을 막으면
193
+ // 거의 모든 고위험 작업이 잠긴다(false-BLOCK). 확실히 아닌 것만 막고, 모르는 것은 모른다고 표시한다.
194
+ // codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다(provider 당 최대 version 5s + status 8s).
195
+ // 차단에 쓰이는 곳은 고위험뿐이므로 **고위험에서만** 확인한다 — tiny/normal 은 지연도 실행도 없다.
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; });
206
+ const notAuthed = assignments.filter(a => a.resolved && a.auth === 'no').map(a => `${a.role}:${a.provider}`);
207
+
145
208
  const blockers = [];
146
209
  if (unresolved.length) blockers.push({ code: 'role_unresolved', message: `역할 미설정: ${unresolved.join(', ')} — leerness roles set <역할> --provider <id>` });
210
+ if (spec.humanApproval && notAuthed.length) blockers.push({ code: 'provider_not_authenticated', message: `인증되지 않은 provider 로 고위험 작업을 확정할 수 없습니다: ${notAuthed.join(', ')} — 해당 CLI 로 로그인 후 leerness agents check 로 재확인` });
147
211
  if (spec.humanApproval && reviewerIndependent === false) blockers.push({ code: 'reviewer_not_independent', message: `검수자와 구현자가 같은 provider(${coder.provider})입니다 — 고위험 작업에서는 독립 검수라고 볼 수 없습니다` });
148
212
  return { tier: cls.tier, ko: spec.ko, humanApproval: spec.humanApproval, assignments, unresolved, reviewerIndependent, blockers };
149
213
  }
@@ -194,11 +258,14 @@ function routeCmd(root, taskText, deps = {}) {
194
258
 
195
259
  const cls = classify(taskText, { tier: (arg ? arg('--tier', '') : '') || '' });
196
260
  if (!cls.ok) { failJson(json, cls.code, cls.error); return; }
197
- const p = plan(root, cls, deps);
198
-
199
261
  const wantDispatch = !!(has && (has('--confirm') || has('--dispatch')));
200
- const approvedBy = _norm(arg ? arg('--approved-by', '') : '');
201
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();
202
269
 
203
270
  // 실행 거부 사유 — 하나라도 있으면 dispatch 하지 않는다(고위험은 fail-closed).
204
271
  const refusals = [];
@@ -212,7 +279,9 @@ function routeCmd(root, taskText, deps = {}) {
212
279
  const confirmed = wantDispatch && !refusals.length;
213
280
  const rec = {
214
281
  at: now(),
215
- 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),
216
285
  tier: cls.tier, confidence: cls.confidence, conflict: !!cls.conflict,
217
286
  signals: cls.signals.map(s => `${s.kind}:${s.id}`),
218
287
  assignments: p.assignments.map(a => ({ role: a.role, provider: a.provider, model: a.model })),
@@ -240,7 +309,12 @@ function routeCmd(root, taskText, deps = {}) {
240
309
  log(`# leerness agents route — 난이도 ${cls.tier} (신뢰도 ${cls.confidence}${cls.conflict ? ' · 규칙 충돌' : ''})`);
241
310
  cls.reasons.forEach(r => log(` · ${r}`));
242
311
  log(`\n 구성(${p.ko}):`);
243
- p.assignments.forEach(a => log(` ${a.role.padEnd(10)} ${a.resolved ? `${a.provider}${a.model ? ' / ' + a.model : ''}` : '⚠ 미설정 — leerness roles set ' + a.role + ' --provider <id>'}`));
312
+ p.assignments.forEach(a => {
313
+ // 사유를 구분해 표시한다 — "확인 안 함"과 "확인할 수단이 없음"은 다른 말이다(헌트 #1).
314
+ const authMark = a.auth === 'ok' ? ' [인증 확인됨]' : a.auth === 'no' ? ' [🔴 미인증]'
315
+ : a.resolved ? (a.authReason === 'checked' ? ' [인증 확인 수단 없음]' : ' [인증 미확인 — 이 등급에서는 확인하지 않음]') : '';
316
+ log(` ${a.role.padEnd(10)} ${a.resolved ? `${a.provider}${a.model ? ' / ' + a.model : ''}${authMark}` : '⚠ 미설정 — leerness roles set ' + a.role + ' --provider <id>'}`);
317
+ });
244
318
  if (p.reviewerIndependent === false) log(` ⚠ 검수자와 구현자가 같은 provider — 독립 검수가 아닙니다`);
245
319
  log(`\n ⓘ ${_AVAIL_NOTE}`);
246
320
  log(` ⓘ ${_CLASS_NOTE} 틀렸다면 --tier ${TIERS.join('|')} 로 지정하세요.`);
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.90",
3
+ "version": "1.36.92",
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
@@ -7801,11 +7801,18 @@ total++;
7801
7801
  const human = R(['agents', 'route', '주문 필터', '--path', d]).o;
7802
7802
  const logHuman = R(['agents', 'route', '--log', '--path', d]).o;
7803
7803
  const cj = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
7804
- const honestOk = /확인하지 않았습니다/.test(human) && /측정값이 아닙니다/.test(human)
7804
+ const honestOk = /확인하지 않습니다/.test(human) && /측정값이 아닙니다/.test(human)
7805
7805
  && !/설치·활성 여부만 확인/.test(human) // 하지 않은 확인을 했다고 말하던 옛 문구
7806
- && /확인하지 않았습니다/.test(logHuman) && /외부 CLI 실행하지 않습니다/.test(logHuman)
7806
+ && /확인하지 않습니다/.test(logHuman) && /작업을 대신 수행하지 않습니다/.test(logHuman)
7807
7807
  && cj.executed === false && typeof cj.confirmed === 'boolean' && !('dispatched' in cj)
7808
- && /외부 CLI 를 실행하지 않습니다/.test(JSON.stringify(cj));
7808
+ // 1.36.91: 인증 축이 생기면서 "외부 CLI 를 전혀 실행하지 않는다"는 **거짓이 됐다**(확정 시 상태 명령을 실행한다).
7809
+ // 1.36.92: "상태 명령만" 도 부정확했다 — `--version` 도 함께 돈다. 문구가 실행 시점과 대상을 정확히
7810
+ // 말하는지 단언하고, **옛 과장 문구 두 형태가 되살아나지 않는지**도 함께 본다.
7811
+ && /모델 호출\(과금\)은 하지 않습니다/.test(JSON.stringify(cj))
7812
+ && /--confirm 으로 확정할 때만/.test(JSON.stringify(cj))
7813
+ && /버전 확인과 로그인 상태 명령을 실행/.test(JSON.stringify(cj))
7814
+ && !/외부 CLI 를 실행하지 않습니다/.test(JSON.stringify(cj))
7815
+ && !/상태 명령만 실행/.test(JSON.stringify(cj));
7809
7816
  // 모델명 비하드코딩 — 정책 모듈에 벤더 모델 id 가 있으면 안 된다(몇 달이면 낡는다)
7810
7817
  // 경로는 CLI 기준으로 잡는다 — 검사 대상이 "지금 실행 중인 그 설치본"이어야 한다(__dirname 은 실행 위치에 따라 어긋난다).
7811
7818
  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'));
@@ -7896,6 +7903,206 @@ total++;
7896
7903
  if (!ok) failed++;
7897
7904
  }
7898
7905
 
7906
+ // 1.36.91: **설치됨 ≠ 사용가능** — 인증 축. 확인 가능한 provider 만 판정하고 나머지는 unknown 으로 남긴다.
7907
+ // unknown 을 차단 사유로 쓰면 확인 수단이 없는 대부분의 CLI 에서 고위험 작업이 전부 잠긴다(false-BLOCK) —
7908
+ // "확실히 아닌 것"만 막는다는 비대칭이 이 축의 핵심이고, 그걸 테스트가 못 박는다.
7909
+ total++;
7910
+ {
7911
+ let ok = false;
7912
+ const _d = [];
7913
+ const R = (args) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 120000 }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
7914
+ const J = (x) => { try { return JSON.parse(x.o.slice(x.o.indexOf('{'))); } catch { return null; } };
7915
+ let A1 = false, A2 = false, A3 = false, A4 = false, A5 = false, A6 = false;
7916
+ try {
7917
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auth-')); _d.push(d);
7918
+ R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
7919
+ const chk = J(R(['agents', 'check', '--path', d, '--json'])) || {};
7920
+ // A1: 인증은 별도 축이고, 판정/미판정이 구분돼 노출된다(설치됨을 사용가능이라 부르지 않는다)
7921
+ A1 = Array.isArray(chk.agents) && chk.agents.length > 0
7922
+ && chk.agents.every(a => ['ok', 'no', 'unknown'].includes(a.auth))
7923
+ && Array.isArray(chk.authUnknown) && chk.authUnknown.length > 0
7924
+ && /쿼터·과금 권한은 어느 provider 도 확인하지 않았/.test(chk.authNote || '');
7925
+ // A2: 확인 명령이 없는 provider 는 반드시 unknown — 추정으로 ok/no 를 붙이지 않는다
7926
+ const reg = require(path.join(path.dirname(CLI), '..', 'lib', 'agent-registry.js')).EXTERNAL_AGENTS;
7927
+ const noCheckIds = reg.filter(a => !a.authCheck).map(a => a.id);
7928
+ // codex 30차 #6: `!a || …` 는 provider 가 응답에서 **빠져도** 통과한다(누락을 성공으로 읽음).
7929
+ // 레지스트리 id 는 반드시 존재해야 하고, 그 다음에 unknown 이어야 한다.
7930
+ A2 = noCheckIds.length > 0 && noCheckIds.every(id => {
7931
+ const a = chk.agents.find(x => x.id === id);
7932
+ return !!a && a.auth === 'unknown';
7933
+ });
7934
+ // A3: `list` 는 인증 확인을 하지 않는다(매 목록 조회마다 외부 프로세스를 띄우면 안 된다)
7935
+ // + **확인이 실제로 수행되는지**를 결정적으로 증명한다. 실제 CLI 설치 여부에 의존하면 그 CLI 가 없는
7936
+ // 머신에서 단언이 공허해지므로, 종료코드를 우리가 정하는 합성 provider 로 메커니즘 자체를 검사한다
7937
+ // (실측: 이 단언이 없을 때 "인증 확인을 통째로 끄는" 변이가 그대로 통과했다).
7938
+ const _ck = require(CLI)._checkAgent;
7939
+ // bin 은 PATH 의 `node` 를 쓴다 — process.execPath 는 공백 포함 경로("C:\\Program Files\\...")라
7940
+ // _checkAgent 의 shell:true 실행에서 깨진다(실측: 기준선이 통째로 실패했다).
7941
+ // args 는 **스크립트 파일 경로**로 준다 — `-e "process.exit(0)"` 는 괄호 때문에 메타문자 가드에
7942
+ // 걸려 실행되지 않는다(실측: 내 가드가 내 테스트를 정확히 거부했다).
7943
+ const _mkScript = (name, body) => { const p = path.join(d, name).replace(/\\/g, '/'); fs.writeFileSync(p, body); return p; };
7944
+ const S_OK = _mkScript('auth_ok.js', 'process.exit(0)');
7945
+ const S_NO = _mkScript('auth_no.js', 'console.log("Not logged in"); process.exit(3)');
7946
+ const S_ODD = _mkScript('auth_odd.js', 'console.log("config parse failed"); process.exit(2)');
7947
+ const mkAgent = (script) => ({ id: 'synthetic', bin: 'node', envFlag: 'LEERNESS_NO_SUCH_FLAG',
7948
+ versionArgs: [S_OK], desc: 'synthetic',
7949
+ authCheck: { args: [script], timeoutMs: 8000, noPattern: 'not logged in|logged out' } });
7950
+ const okRes = _ck(mkAgent(S_OK), { auth: true });
7951
+ const noRes = _ck(mkAgent(S_NO), { auth: true });
7952
+ const offRes = _ck(mkAgent(S_OK), {}); // opts.auth 없으면 확인하지 않는다
7953
+ // codex 30차 #2: **nonzero ≠ 미인증**. 로그아웃으로 알아볼 문구가 없으면 unknown 이어야 한다 —
7954
+ // 구버전·설정오류·내부오류를 no 로 읽으면 멀쩡한 사용자의 고위험 작업이 잠긴다(false-BLOCK).
7955
+ const oddRes = _ck(mkAgent(S_ODD), { auth: true });
7956
+ // + 실행 실패를 "확실히 미인증"으로 둔갑시키지 않는다: `no` 는 고위험을 차단하므로, 확인 명령 자체가
7957
+ // 깨졌을 때 no 를 내면 멀쩡한 사용자가 잠긴다(실측: 크래시하는 확인 명령이 no 로 읽혔다).
7958
+ // + evidence 는 사람이 보는 출력에 실리므로 토큰이 섞여 나오면 안 된다.
7959
+ const crashScript = path.join(d, 'authcrash.js').replace(/\\/g, '/');
7960
+ fs.writeFileSync(crashScript, 'throw new Error("broken check")');
7961
+ const leakScript = path.join(d, 'authleak.js').replace(/\\/g, '/');
7962
+ fs.writeFileSync(leakScript, 'console.log("token=sk-live-LEAKME ok"); process.exit(0)');
7963
+ const fileAgent = (f) => ({ id: 'syn', bin: 'node', envFlag: 'LEERNESS_NO_SUCH_FLAG', versionArgs: ['-e', 'process.exit(0)'], desc: 's',
7964
+ authCheck: { args: [f], timeoutMs: 8000 } });
7965
+ const crashRes = _ck(fileAgent(crashScript), { auth: true });
7966
+ const leakRes = _ck(fileAgent(leakScript), { auth: true });
7967
+ // 셸 메타문자가 든 args 는 실행 자체를 거부한다(인용이 깨져 엉뚱한 실행이 되고 주입 표면이 된다)
7968
+ const metaRes = _ck({ id: 'syn', bin: 'node', envFlag: 'X', versionArgs: ['-e', 'process.exit(0)'], desc: 's',
7969
+ authCheck: { args: ['-e', 'console.log(1) && whoami'], timeoutMs: 3000 } }, { auth: true });
7970
+ // codex 30차 #5: 제목 부재만 보면 "list 가 auth:true 로 부르되 표만 그대로 내는" 변경이 통과한다.
7971
+ // list 의 **데이터**에도 판정이 실리지 않는지 함께 본다(auth 가 전부 unknown 이어야 한다).
7972
+ const listJson = J(R(['agents', 'list', '--path', d, '--json']));
7973
+ const listNoAuth = !listJson || !Array.isArray(listJson.agents)
7974
+ || listJson.agents.every(a => a.auth === undefined || a.auth === 'unknown');
7975
+ A3 = !/인증 확인 \(1\.36\.91\)/.test(R(['agents', 'list', '--path', d]).o) && listNoAuth
7976
+ && okRes.auth === 'ok' && noRes.auth === 'no' && offRes.auth === 'unknown'
7977
+ && oddRes.auth === 'unknown' // nonzero 인데 로그아웃 문구 없음 → 판정 불가
7978
+ && typeof okRes.authSource === 'string' && okRes.authSource.length > 0
7979
+ && crashRes.auth === 'unknown'
7980
+ && leakRes.auth === 'ok' && !/sk-live-LEAKME/.test(String(leakRes.authEvidence)) && /token=\*\*\*/.test(String(leakRes.authEvidence))
7981
+ && metaRes.auth === 'unknown' && metaRes.authSource === null;
7982
+ // A4~A6: 라우팅 연동 — unknown 은 통과, 확실한 미인증만 고위험에서 차단, normal 은 무관
7983
+ const RTm = require(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'));
7984
+ const roles = { architect: 'codex', commander: 'codex', coder: 'codex', reviewer: 'claude' };
7985
+ // 1.36.92: 인증 확인은 **확정 요청(authAllowed)** 일 때만 돈다 — 제안이 외부 프로세스를 띄우던 동의 위반을 막았다.
7986
+ // 그래서 차단 판정을 보려면 authAllowed:true 를 줘야 하고, 주지 않으면 확인 자체가 없어야 한다(판별 대조).
7987
+ const planWith = (tier, authMap, allowed = true) => RTm.plan(d, { tier }, {
7988
+ authAllowed: allowed,
7989
+ _resolveRole: (r, role) => ({ provider: roles[role] }),
7990
+ _providerAuth: (pid) => authMap[pid] || 'unknown',
7991
+ });
7992
+ const hasAuthBlock = (p) => p.blockers.some(b => b.code === 'provider_not_authenticated');
7993
+ A4 = !hasAuthBlock(planWith('high-risk', {})) // 전부 unknown → 막지 않는다
7994
+ && !hasAuthBlock(planWith('high-risk', { codex: 'no' }, false)); // 확정 요청 없으면 확인도 차단도 없다
7995
+ A5 = hasAuthBlock(planWith('high-risk', { codex: 'no', claude: 'ok' })); // 확실한 미인증 → 막는다
7996
+ // codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다 — 차단에 쓰이는 고위험에서만 확인해야
7997
+ // tiny/normal 에 지연이 붙지 않고, "여기서 CLI 를 실행한다"는 범위도 좁게 유지된다.
7998
+ // 호출 횟수를 직접 센다(결과만 보면 "확인은 하고 무시하는" 구현과 구분되지 않는다).
7999
+ let calls = 0;
8000
+ const counting = (tier, allowed) => RTm.plan(d, { tier }, {
8001
+ authAllowed: allowed,
8002
+ _resolveRole: (r, role) => ({ provider: roles[role] }),
8003
+ _providerAuth: () => { calls++; return 'no'; },
8004
+ });
8005
+ calls = 0; const normalPlan = counting('normal', true); const normalCalls = calls;
8006
+ calls = 0; counting('high-risk', true); const highCalls = calls;
8007
+ calls = 0; counting('high-risk', false); const noConsentCalls = calls;
8008
+ A6 = !hasAuthBlock(planWith('normal', { codex: 'no', claude: 'no' })) // normal 은 무관
8009
+ && !hasAuthBlock(normalPlan) && normalCalls === 0 && highCalls > 0 // normal 은 확인 자체를 안 한다
8010
+ && noConsentCalls === 0 // 확정 요청 없으면 고위험도 확인 안 한다(동의)
8011
+ && planWith('high-risk', {}).assignments.every(a => 'auth' in a); // auth 축이 계획에 실린다
8012
+ ok = A1 && A2 && A3 && A4 && A5 && A6;
8013
+ if (!ok) console.log(` [인증축 디버그] 축노출=${A1} 미확인은unknown=${A2} list무확인=${A3} unknown통과=${A4} 미인증차단=${A5} normal무관+축노출=${A6}`);
8014
+ } catch (e) { console.log(' [인증축 디버그] 예외: ' + ((e && e.message) || e)); }
8015
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8016
+ console.log(ok ? '✓ B(1.36.91) 인증 축(설치됨≠사용가능): 확인 명령 있는 provider 만 ok/no · 나머지는 unknown 유지 · list 는 미확인(지연) · 라우팅은 unknown 통과 / 확실한 미인증만 고위험 차단' : '✗ 인증 축 실패');
8017
+ if (!ok) failed++;
8018
+ }
8019
+
8020
+ // 1.36.92 (신규표면 헌트 확정 5건): 동의 없는 외부 CLI 실행 · 사유 오귀인 · 다국어 과소평가 ·
8021
+ // 위치경로 무시 · Bearer 토큰 미마스킹 · 손상 토글 무경고.
8022
+ // **PATH shim** 으로 "프로세스를 띄우지 않는다"를 증명한다 — 종전 테스트에는 그걸 증명할 수단이 아예 없었다.
8023
+ total++;
8024
+ {
8025
+ let ok = false;
8026
+ const _d = [];
8027
+ let N1 = false, N2 = false, N3 = false, N4 = false, N5 = false;
8028
+ try {
8029
+ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-shim-')); _d.push(shimDir);
8030
+ const shimLog = path.join(shimDir, 'calls.log');
8031
+ const isWin = process.platform === 'win32';
8032
+ // codex 32차 #8: shim 이 codex 하나뿐이면 다른 provider 로 새는 회귀를 못 잡는다 — 배치된 provider 전부에 건다.
8033
+ for (const binName of ['codex', 'claude']) {
8034
+ 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`);
8035
+ 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 {} }
8036
+ }
8037
+ // provider opt-in 을 **켠 상태**로 돌린다 — 그래야 "확정에서는 실행된다"는 대조가 성립하고,
8038
+ // 아래 N1b 가 "끄면 실행되지 않는다"를 별도로 확인한다(문서화된 opt-out 의 실제 검증).
8039
+ const env = Object.assign({}, process.env, {
8040
+ PATH: shimDir + path.delimiter + process.env.PATH,
8041
+ LEERNESS_ENABLE_CODEX: '1', LEERNESS_ENABLE_CLAUDE: '1',
8042
+ });
8043
+ const envOff = Object.assign({}, env, { LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_CLAUDE: '0' });
8044
+ 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() }; };
8045
+ const calls = () => { try { return fs.readFileSync(shimLog, 'utf8').trim().split('\n').filter(Boolean).length; } catch { return 0; } };
8046
+ const clear = () => { try { fs.rmSync(shimLog, { force: true }); } catch {} };
8047
+
8048
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h2-')); _d.push(d);
8049
+ RS(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
8050
+ ['commander', 'coder', 'architect'].forEach(x => RS(['roles', 'set', x, '--provider', 'codex', '--path', d]));
8051
+ RS(['roles', 'set', 'reviewer', '--provider', 'claude', '--path', d]);
8052
+ // N1: **제안은 외부 프로세스를 띄우지 않는다**(동의 전 실행 금지). 확정에서는 띄운다 — 판별 대조.
8053
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--path', d]);
8054
+ const suggestCalls = calls();
8055
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--path', d, '--json']);
8056
+ const suggestJsonCalls = calls();
8057
+ RS(['toggle', 'set', 'difficulty-routing', 'on', '--path', d]);
8058
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--confirm', '--approved-by', '검증자', '--path', d]);
8059
+ const confirmCalls = calls();
8060
+ // N1b: **문서화된 opt-out 을 실제로 지키는지** — LEERNESS_ENABLE_* 를 끄면 확정에서도 띄우지 않는다.
8061
+ // (종전엔 이 플래그가 이 경로에서 완전한 no-op 이라, 외부 도구를 껐다고 믿는 사용자 환경에서 CLI 가 돌았다.)
8062
+ clear(); RS(['agents', 'route', 'implement oauth token refresh', '--confirm', '--approved-by', '검증자', '--path', d], envOff);
8063
+ const optOutCalls = calls();
8064
+ N1 = suggestCalls === 0 && suggestJsonCalls === 0 && confirmCalls > 0 && optOutCalls === 0;
8065
+ // N2: "확인 안 함"과 "확인 수단 없음"을 구분해 말한다(같은 provider 가 등급에 따라 갈리던 오귀인)
8066
+ const tinyOut = RS(['agents', 'route', 'fix typo in button label', '--path', d]).o;
8067
+ N2 = /이 등급에서는 확인하지 않음/.test(tinyOut)
8068
+ && !/무료·비대화형 확인 명령이 있는 provider 만 판정한 결과/.test(tinyOut)
8069
+ && /버전 확인과 로그인 상태 명령을 실행/.test(RS(['agents', 'route', '--log', '--path', d]).o);
8070
+ // N3: 규칙이 못 읽는 문자 체계가 섞이면 tiny 로 내리지 않는다(결제·인증이 tiny 로 확정되던 구멍)
8071
+ const RTm = require(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'));
8072
+ N3 = RTm.classify('支付退款逻辑修改 css').tier === 'normal'
8073
+ && RTm.classify('決済リファンド css 修正').tier === 'normal'
8074
+ && RTm.classify('fix typo in css').tier === 'tiny' // 대조군: 커버되는 언어는 그대로 tiny
8075
+ // 전각 문자로 고위험 규칙을 우회할 수 있었다(PAYMENT → normal) — 매칭 전 NFKC 정규화
8076
+ && RTm.classify('PAYMENT refund logic').tier === 'high-risk';
8077
+ // N4: bugfix start 가 위치 경로를 인식한다(cwd 오염 없이 대상 프로젝트에 등록)
8078
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h2b-')); _d.push(d2);
8079
+ RS(['init', d2, '--yes', '--language', 'ko', '--no-stale-check']);
8080
+ const aid = ((RS(['task', 'add', '대상 작업', '--path', d2, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
8081
+ fs.writeFileSync(path.join(d2, 'app.js'), 'console.error("BUG_HERE"); process.exit(3);');
8082
+ const stRes = RS(['bugfix', 'start', aid, '--repro', 'node app.js', '--expect-bad', 'BUG_HERE', d2]);
8083
+ N4 = stRes.s === 0 && fs.existsSync(path.join(d2, '.harness', 'bugfix-receipts.json'));
8084
+ // N5: Bearer/Basic 토큰 마스킹 + 손상 토글 무경고 금지
8085
+ const red = require(path.join(path.dirname(CLI), '..', 'lib', 'pure-utils.js')).redactSecrets;
8086
+ const maskOk = !red('Authorization: Bearer sk-live-ABCDEFGH12345', 200).includes('sk-live-ABCDEFGH12345')
8087
+ && !red('curl -H "Authorization: Basic dXNlcjpwYXNz" api', 200).includes('dXNlcjpwYXNz');
8088
+ fs.writeFileSync(path.join(d, '.harness', 'toggles.json'), '{손상');
8089
+ const tl = RS(['toggle', 'list', '--path', d]);
8090
+ let tj = null; try { const o = RS(['toggle', 'list', '--path', d, '--json']).o; tj = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
8091
+ // 감사 기록은 **입력 원문**(분류용 정규화본이 아님)을 남기되, git 추적 파일이므로 시크릿은 가린다
8092
+ const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h2c-')); _d.push(d3);
8093
+ RS(['init', d3, '--yes', '--language', 'ko', '--no-stale-check']);
8094
+ RS(['agents', 'route', 'PAYMENT 수정 API_TOKEN=sk-live-SECRET123', '--path', d3]);
8095
+ const lgRaw = fs.readFileSync(path.join(d3, '.harness', 'routing-log.json'), 'utf8');
8096
+ const auditOk = !/sk-live-SECRET123/.test(lgRaw) && /API_TOKEN=\*\*\*/.test(lgRaw) && /PAYMENT/.test(lgRaw);
8097
+ N5 = maskOk && auditOk && /toggles\.json 손상/.test(tl.o) && !!tj && tj.corrupt === true;
8098
+ ok = N1 && N2 && N3 && N4 && N5;
8099
+ if (!ok) console.log(` [헌트5 디버그] 제안무실행=${N1}(제안 ${suggestCalls}/${suggestJsonCalls} · 확정 ${confirmCalls} · optout ${optOutCalls}) 사유구분=${N2} 다국어=${N3} 위치경로=${N4} 마스킹+손상고지=${N5}`);
8100
+ } catch (e) { console.log(' [헌트5 디버그] 예외: ' + ((e && e.message) || e)); }
8101
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8102
+ console.log(ok ? '✓ B(1.36.92) 신규표면 헌트 5건: 제안은 외부 프로세스 0회(PATH shim 증명)·확정에서만 실행 · 인증 미확인 사유 구분 · 미커버 문자체계는 tiny 금지 · bugfix 위치경로 인식 · Bearer 마스킹 + 손상 토글 경고' : '✗ 신규표면 헌트 5건 실패');
8103
+ if (!ok) failed++;
8104
+ }
8105
+
7899
8106
  // 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
7900
8107
  total++;
7901
8108
  {