leerness 1.36.91 → 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,34 @@
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
+
3
32
  ## 1.36.91 — 2026-07-30 — 인증 축: **설치됨 ≠ 사용가능**을 고지에서 실제 판정으로
4
33
 
5
34
  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.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.91는 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.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.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.91: 2026-07-30
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.91';
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), () => {
@@ -24107,7 +24123,11 @@ async function main() {
24107
24123
  const _providerAuth = (pid) => {
24108
24124
  if (_authCache.has(pid)) return _authCache.get(pid);
24109
24125
  const def = (_allProviders(_authRoot) || []).find(a => a.id === pid);
24110
- const v = (def && def.authCheck) ? (_checkAgent(def, { auth: true }).auth || 'unknown') : 'unknown';
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';
24111
24131
  _authCache.set(pid, v);
24112
24132
  return v;
24113
24133
  };
@@ -24270,9 +24290,27 @@ async function main() {
24270
24290
  // 등록된 probe 를 "없음"으로 보고했다 — 다른 명령들과 같은 규율로 위치 경로를 받는다.
24271
24291
  // (start/receipt/drop 의 첫 토큰은 task id 이므로, 경로로 오인하지 않도록 list/무인자에서만 적용)
24272
24292
  const _bfSub = _bfToks[0];
24273
- const _bfRest = _bfToks.slice(1);
24293
+ let _bfRest = _bfToks.slice(1);
24274
24294
  let _bfRoot = arg('--path', null);
24275
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
+ }
24276
24314
  // _withLock: 동시 등록이 서로를 지우면 게이트가 조용히 꺼진다(실측 4→2)
24277
24315
  // _taskExists: 존재하지 않는 id 로 등록되면 오타 하나로 "보호받는 줄 아는" 무보호 상태가 된다(출하전 헌트 #3)
24278
24316
  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.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,15 +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) && /작업을 대신 수행하지 않습니다/.test(logHuman)
7806
+ && /확인하지 않습니다/.test(logHuman) && /작업을 대신 수행하지 않습니다/.test(logHuman)
7807
7807
  && cj.executed === false && typeof cj.confirmed === 'boolean' && !('dispatched' in cj)
7808
- // 1.36.91: 인증 축이 생기면서 "외부 CLI 를 전혀 실행하지 않는다"는 **거짓이 됐다**(고위험에서 상태 명령을 실행한다).
7809
- // 문구가 구분을 담고 있는지 단언하고, 과장 문구가 되살아나지 않는지도 함께 본다.
7808
+ // 1.36.91: 인증 축이 생기면서 "외부 CLI 를 전혀 실행하지 않는다"는 **거짓이 됐다**(확정 상태 명령을 실행한다).
7809
+ // 1.36.92: "상태 명령만" 부정확했다 `--version` 함께 돈다. 문구가 실행 시점과 대상을 정확히
7810
+ // 말하는지 단언하고, **옛 과장 문구 두 형태가 되살아나지 않는지**도 함께 본다.
7810
7811
  && /모델 호출\(과금\)은 하지 않습니다/.test(JSON.stringify(cj))
7811
- && /인증 확인을 위해 해당 CLI 의 상태 명령만 실행/.test(JSON.stringify(cj))
7812
- && !/외부 CLI 실행하지 않습니다/.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));
7813
7816
  // 모델명 비하드코딩 — 정책 모듈에 벤더 모델 id 가 있으면 안 된다(몇 달이면 낡는다)
7814
7817
  // 경로는 CLI 기준으로 잡는다 — 검사 대상이 "지금 실행 중인 그 설치본"이어야 한다(__dirname 은 실행 위치에 따라 어긋난다).
7815
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'));
@@ -7979,25 +7982,32 @@ total++;
7979
7982
  // A4~A6: 라우팅 연동 — unknown 은 통과, 확실한 미인증만 고위험에서 차단, normal 은 무관
7980
7983
  const RTm = require(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'));
7981
7984
  const roles = { architect: 'codex', commander: 'codex', coder: 'codex', reviewer: 'claude' };
7982
- const planWith = (tier, authMap) => RTm.plan(d, { tier }, {
7985
+ // 1.36.92: 인증 확인은 **확정 요청(authAllowed)** 때만 돈다 제안이 외부 프로세스를 띄우던 동의 위반을 막았다.
7986
+ // 그래서 차단 판정을 보려면 authAllowed:true 를 줘야 하고, 주지 않으면 확인 자체가 없어야 한다(판별 대조).
7987
+ const planWith = (tier, authMap, allowed = true) => RTm.plan(d, { tier }, {
7988
+ authAllowed: allowed,
7983
7989
  _resolveRole: (r, role) => ({ provider: roles[role] }),
7984
7990
  _providerAuth: (pid) => authMap[pid] || 'unknown',
7985
7991
  });
7986
7992
  const hasAuthBlock = (p) => p.blockers.some(b => b.code === 'provider_not_authenticated');
7987
- A4 = !hasAuthBlock(planWith('high-risk', {})); // 전부 unknown → 막지 않는다
7993
+ A4 = !hasAuthBlock(planWith('high-risk', {})) // 전부 unknown → 막지 않는다
7994
+ && !hasAuthBlock(planWith('high-risk', { codex: 'no' }, false)); // 확정 요청 없으면 확인도 차단도 없다
7988
7995
  A5 = hasAuthBlock(planWith('high-risk', { codex: 'no', claude: 'ok' })); // 확실한 미인증 → 막는다
7989
7996
  // codex 30차 #3: 인증 확인은 외부 프로세스를 띄운다 — 차단에 쓰이는 고위험에서만 확인해야
7990
7997
  // tiny/normal 에 지연이 붙지 않고, "여기서 CLI 를 실행한다"는 범위도 좁게 유지된다.
7991
7998
  // 호출 횟수를 직접 센다(결과만 보면 "확인은 하고 무시하는" 구현과 구분되지 않는다).
7992
7999
  let calls = 0;
7993
- const counting = (tier) => RTm.plan(d, { tier }, {
8000
+ const counting = (tier, allowed) => RTm.plan(d, { tier }, {
8001
+ authAllowed: allowed,
7994
8002
  _resolveRole: (r, role) => ({ provider: roles[role] }),
7995
8003
  _providerAuth: () => { calls++; return 'no'; },
7996
8004
  });
7997
- calls = 0; const normalPlan = counting('normal'); const normalCalls = calls;
7998
- calls = 0; counting('high-risk'); const highCalls = calls;
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;
7999
8008
  A6 = !hasAuthBlock(planWith('normal', { codex: 'no', claude: 'no' })) // normal 은 무관
8000
8009
  && !hasAuthBlock(normalPlan) && normalCalls === 0 && highCalls > 0 // normal 은 확인 자체를 안 한다
8010
+ && noConsentCalls === 0 // 확정 요청 없으면 고위험도 확인 안 한다(동의)
8001
8011
  && planWith('high-risk', {}).assignments.every(a => 'auth' in a); // auth 축이 계획에 실린다
8002
8012
  ok = A1 && A2 && A3 && A4 && A5 && A6;
8003
8013
  if (!ok) console.log(` [인증축 디버그] 축노출=${A1} 미확인은unknown=${A2} list무확인=${A3} unknown통과=${A4} 미인증차단=${A5} normal무관+축노출=${A6}`);
@@ -8007,6 +8017,92 @@ total++;
8007
8017
  if (!ok) failed++;
8008
8018
  }
8009
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
+
8010
8106
  // 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
8011
8107
  total++;
8012
8108
  {