leerness 1.9.287 → 1.9.289

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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.289 — 2026-06-03 — UR-0036 (Codex #3): REPL agy/copilot 프롬프트 셸 주입 차단
4
+
5
+ **🔒 REPL 스트리밍에서 agy/copilot 프롬프트가 `shell:true` 인자로 raw 전달돼 셸 분리/주입되던 보안 갭 수정 (Codex gpt-5.5 #3).**
6
+
7
+ ### 배경
8
+ claude/codex 는 1.9.188 에서 프롬프트를 stdin 으로 전달(셸 escape 우회)하나, agy(`-p`)/copilot(`gh copilot suggest`)은 인자 모드만 지원해 `promptText` 를 args 에 raw 로 넣었음. `cp.spawn(cmd, args, {shell:true})` 는 args 를 따옴표 없이 조인하므로 `agy -p hello; rm -rf` 처럼 프롬프트의 `;`/`&&`/`$(...)`/공백이 셸에 해석됨.
9
+
10
+ ### 구현
11
+ 1. **`_shellQuoteArg(s)` 순수 헬퍼** — POSIX(sh): single-quote(bulletproof, 내부 `'`→`'\''`). Windows(cmd.exe): double-quote + 내부 `"`→`""` (공백/`&`/`|`/`<`/`>`/`(`/`)`/`;`/`$()` 모두 리터럴화).
12
+ 2. **agy/copilot args 안전 인용** — `['-p', _shellQuoteArg(promptText)]` / `['copilot','suggest',_shellQuoteArg(promptText)]`. 프롬프트가 단일 리터럴 인자로 전달.
13
+ 3. selftest 36→37 + e2e 233→234.
14
+
15
+ ### 검증
16
+ - **selftest 37/37 PASS** · **E2E 234/234 PASS** (회귀 0) · `a; rm -rf / && echo $(whoami)` → 단일 인용 리터럴 확인 (POSIX/Windows 분기).
17
+ - 남은 백로그: UR-0037(#4) require 시 top-level side effect 격리.
18
+
19
+ ## 1.9.288 — 2026-06-03 — Codex gpt-5.5 코드 리뷰 수렴: MCP policy enforce + release dry-run + 도구수 정합
20
+
21
+ **🔒 Codex(gpt-5.5, xhigh)가 실제 코드 라인 근거로 제시한 5건 중 검증된 high/med 3건 수렴 + 나머지 2건 백로그.**
22
+
23
+ ### 배경
24
+ codex CLI(gpt-5.5)로 leerness@1.9.287 을 직접 리뷰(샌드박스 우회로 파일 접근, 285k 토큰). 5건 모두 코드 라인 근거의 타당한 지적. 이번 라운드에 검증·테스트 가능한 3건 반영.
25
+
26
+ ### 구현 (Codex 지적 → 수렴)
27
+ 1. **#1 (high) MCP policy enforce 우회** — `_policyEnforce` 가 `agents multi --execute` 한 곳뿐이라 MCP `state_start` 등 write 도구가 정책을 우회. → MCP `tools/call` 의 `callLeerness` 직전에 **중앙 정책 게이트** 추가. enforce ON 시 허용 등급 초과 도구는 JSON-RPC `isError` 로 차단(실행 안 함). read-only enforce 에서 MCP state_start 차단 + state.json 미생성 실측.
28
+ 2. **#2 (high) `release publish --dry-run` 이 실제 push + 실패도 성공 처리** — dry-run 인데 npm pack/git push 실행, status 미검사로 실패 시에도 exit 0. → dry-run 은 모든 외부 side effect(pack/push/gh/pages)를 **계획 출력만**, live 는 git push status 실패 시 **non-zero 종료**.
29
+ 3. **#5 (med) 도구 수 불일치** — 배지 80 / 관리블록 42 / capability 카운트 자기-매칭 80 vs 실제 tools/list 79. → `_mcpToolCount()` 단일 출처(정확한 도구 정의 패턴)로 배지·관리블록·capability·CHANGELOG 카운트 통일. syncReadme 가 MCP 배지 자동 동기화(stale 방지).
30
+
31
+ ### 백로그 (Codex #3/#4)
32
+ - **UR-0036** (#3): REPL agy/copilot prompt 가 shell:true args 전달(셸 주입 위험) → stdin/shell-safe 통일.
33
+ - **UR-0037** (#4): require.main 가드에도 top-level side effect(warning listener/NODE_OPTIONS/chcp) 가 require 시 실행 → main() 내부 이동.
34
+
35
+ ### 검증
36
+ - **selftest 36/36 PASS** · **E2E 233/233 PASS** (회귀 0) · MCP 정책 차단(write)/허용(enforce off) + release dry-run 무push exit 0 + 배지==tools/list(79) 실측.
37
+
3
38
  ## 1.9.287 — 2026-06-03 — Codex 외부 리뷰 수렴: 허위 완료 차단 강화 + handoff 펜스 + status minimal
4
39
 
5
40
  **🛡️ 다른 AI(Codex)가 실측한 핵심 한계 "테스트만 통과하면 미구현도 done 통과"를 보강 + 발견된 품질 버그 2건 수정.**
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
  > **A CLI harness that stops AI coding agents from faking completion, duplicating work, forgetting context, and colliding.**
5
5
 
6
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.287-green)]() [![tests](https://img.shields.io/badge/e2e-232%2F232-success)]() [![selftest](https://img.shields.io/badge/selftest-35-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-80-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.289-green)]() [![tests](https://img.shields.io/badge/e2e-234%2F234-success)]() [![selftest](https://img.shields.io/badge/selftest-37-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-79-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
7
7
 
8
8
  ```
9
9
  ╔══════════════════════════════════════════════════════════════╗
@@ -471,7 +471,7 @@ MIT — © leerness contributors
471
471
  <!-- leerness:project-readme:start -->
472
472
  ## Leerness Project Harness
473
473
 
474
- 이 프로젝트는 Leerness v1.9.287 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.289 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
475
475
 
476
476
  ### Core Commands
477
477
 
@@ -513,7 +513,7 @@ leerness memory restore decision <date|title>
513
513
 
514
514
  ### MCP server (외부 AI 통합)
515
515
 
516
- Leerness v1.9.287는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **42개 도구**를 노출:
516
+ Leerness v1.9.289는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **79개 도구**를 노출:
517
517
 
518
518
  ```jsonc
519
519
  // 카테고리별
@@ -526,7 +526,7 @@ Leerness v1.9.287는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
526
526
  // • Workflow: session_close / agents_list / task_export / env_check / usage_stats / reuse_map / whats_new
527
527
 
528
528
  // MCP server 실행: leerness mcp serve
529
- // tools/list 응답: 42 도구
529
+ // tools/list 응답: 79 도구
530
530
  ```
531
531
 
532
532
  ### Autonomous mode (자율 모드)
@@ -534,7 +534,7 @@ Leerness v1.9.287는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
534
534
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
535
535
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
536
536
 
537
- 현재 누적: **70 라운드 (1.9.40 → 1.9.287)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
537
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.289)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
538
538
 
539
539
  ### 성능 가이드 (1.9.140 측정)
540
540
 
@@ -572,6 +572,6 @@ leerness release pack --close --auto-main-push
572
572
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
573
573
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
574
574
 
575
- Last synced by Leerness v1.9.287: 2026-06-03
575
+ Last synced by Leerness v1.9.289: 2026-06-03
576
576
  <!-- leerness:project-readme:end -->
577
577
 
package/bin/harness.js CHANGED
@@ -10,7 +10,7 @@ const readline = require('readline');
10
10
  const { _isSecretKey, compareVer, parseHarnessVersion, _classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
11
11
  PERMISSION_TIERS, _tierRank, _requiredTier, _policyAllows, _resolveNpmTag, _mcpJsonContent, _newRunRecord } = require('../lib/pure-utils');
12
12
 
13
- const VERSION = '1.9.287';
13
+ const VERSION = '1.9.289';
14
14
 
15
15
  // 1.9.184: DEP0190 (child_process shell: true) deprecation warning 억제 (사용자 명시).
16
16
  // leerness 는 cross-platform PATH resolution 을 위해 shell: true 를 의도적으로 사용 (claude.cmd / ollama.cmd 등 Windows .cmd 처리).
@@ -152,6 +152,18 @@ function fail(s) { log('✗ ' + s); }
152
152
  // 1.9.287 (Codex 리뷰 수렴): 임베딩 텍스트의 코드펜스(```)를 중립화해 외부 마크다운(session-handoff)이 깨지지 않게.
153
153
  // ``` → ''' (펜스가 아닌 표시), 인라인 백틱은 보존. 순수 함수.
154
154
  function _sanitizeFences(s) { return String(s || '').replace(/```+/g, "'''"); }
155
+ // 1.9.289 (Codex gpt-5.5 리뷰 #3 수렴): shell:true spawn 의 인자를 셸-안전하게 인용 — 프롬프트 셸 주입/분리 방지.
156
+ // POSIX(sh): single-quote (bulletproof). Windows(cmd.exe): double-quote + inner " 이스케이프 (공백/&|<>() 안전).
157
+ function _shellQuoteArg(s) {
158
+ s = String(s == null ? '' : s);
159
+ if (process.platform === 'win32') return '"' + s.replace(/"/g, '""') + '"';
160
+ return "'" + s.replace(/'/g, "'\\''") + "'";
161
+ }
162
+ // 1.9.288 (Codex gpt-5.5 리뷰 #5 수렴): MCP 도구 수 단일 출처 — tools 정의 패턴만 카운트(설명/자기-매칭 오탐 제외).
163
+ // 배지/관리블록/capability 가 모두 이 값을 써서 tools/list 실제 수와 일치.
164
+ function _mcpToolCount() {
165
+ try { return (read(__filename).match(/\{ name: 'leerness_[a-z_]+', description:/g) || []).length; } catch { return 0; }
166
+ }
155
167
  function absRoot(p) { return path.resolve(p || process.cwd()); }
156
168
  function exists(p) { return fs.existsSync(p); }
157
169
  function read(p) {
@@ -317,7 +329,7 @@ function managedReadmeBlock(project) {
317
329
  '',
318
330
  '### MCP server (외부 AI 통합)',
319
331
  '',
320
- `Leerness v${VERSION}는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **42개 도구**를 노출:`,
332
+ `Leerness v${VERSION}는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **${_mcpToolCount()}개 도구**를 노출:`,
321
333
  '',
322
334
  '```jsonc',
323
335
  '// 카테고리별',
@@ -330,7 +342,7 @@ function managedReadmeBlock(project) {
330
342
  '// • Workflow: session_close / agents_list / task_export / env_check / usage_stats / reuse_map / whats_new',
331
343
  '',
332
344
  '// MCP server 실행: leerness mcp serve',
333
- '// tools/list 응답: 42 도구',
345
+ `// tools/list 응답: ${_mcpToolCount()} 도구`,
334
346
  '```',
335
347
  '',
336
348
  '### Autonomous mode (자율 모드)',
@@ -813,6 +825,11 @@ function syncReadme(root) {
813
825
  const stCount = _selfTestCases().length;
814
826
  if (stCount > 0) updated = updated.replace(/badge\/selftest-\d+(?:%2F\d+)?/g, `badge/selftest-${stCount}`);
815
827
  } catch {}
828
+ // 1.9.288 (Codex #5): MCP 도구 배지 자동 동기화 — tools/list 실제 수와 일치 (badge 80↔실제 79 불일치 해소).
829
+ try {
830
+ const mcpN = _mcpToolCount();
831
+ if (mcpN > 0) updated = updated.replace(/badge\/MCP--tools-\d+/g, `badge/MCP--tools-${mcpN}`);
832
+ } catch {}
816
833
  } catch {}
817
834
  if (updated !== existing) writeUtf8(p, updated);
818
835
  ok('README.md Leerness section synced');
@@ -2972,6 +2989,8 @@ function _selfTestCases() {
2972
2989
  { name: 'CAPABILITY_SURFACE: 6 영역 + risk/optOut + 주의명령 (1.9.272)', run: () => { const ks = Object.keys(CAPABILITY_SURFACE); return ks.length === 6 && ks.includes('automationBridges') && Object.values(CAPABILITY_SURFACE).every(v => ['low','medium','high'].includes(v.risk) && v.optOut && v.desc) && POWERFUL_COMMANDS.length >= 5; } },
2973
2990
  { name: '_resolveNpmTag: latest 기본 + next 허용 + 잘못된 형식 폴백 (1.9.275)', run: () => _resolveNpmTag(null, {}) === 'latest' && _resolveNpmTag('next', {}) === 'next' && _resolveNpmTag(null, { LEERNESS_NPM_TAG: 'next' }) === 'next' && _resolveNpmTag('bad tag!', {}) === 'latest' && _resolveNpmTag('Beta', {}) === 'beta' },
2974
2991
  { name: '_sanitizeFences: 코드펜스 중립화 (Codex 수렴 1.9.287)', run: () => { const out = _sanitizeFences('text\n```js\ncode\n```\nmore'); return !/```/.test(out) && /'''/.test(out) && /code/.test(out); } },
2992
+ { name: '_mcpToolCount: 실제 도구 정의 수 (자기-매칭 오탐 없음) (Codex #5 1.9.288)', run: () => { const n = _mcpToolCount(); return n >= 75 && n < 200; } },
2993
+ { name: '_shellQuoteArg: 프롬프트 셸 주입 중립화 (Codex #3 1.9.289)', run: () => { const q = _shellQuoteArg('a; rm -rf / && echo x'); if (process.platform === 'win32') return q === '"a; rm -rf / && echo x"' && _shellQuoteArg('a"b') === '"a""b"'; return q === "'a; rm -rf / && echo x'" && _shellQuoteArg("it's") === "'it'\\''s'"; } },
2975
2994
  { name: '_evidenceQuality: 파일+테스트 근거 강제 (Codex 수렴 1.9.287)', run: () => { const good = _evidenceQuality('src/api.js 수정, npm test 12/12 통과 (Exit: 0)'); const weak = _evidenceQuality('테스트 통과함'); const noFile = _evidenceQuality('12 tests passed'); return good.ok === true && good.hasFile && good.hasTest && weak.ok === false && weak.missing.includes('수정 파일 경로') && noFile.ok === false; } },
2976
2995
  { name: '_parseEvidenceStats: review-evidence pass/fail 집계 (1.9.286)', run: () => { const s = _parseEvidenceStats('## 2026-06-03\nCommand: npm test\nExit: 0\n\n## 2026-06-03\nCommand: build\nExit: 1\n\n## 2026-06-03\nverify PASS 통과\n'); return s.entries === 3 && s.pass === 2 && s.fail === 1 && s.rate === 67; } },
2977
2996
  { name: '_reuseDetect: 키워드→OSS 카테고리 + 체크리스트 (1.9.285)', run: () => { const a = _reuseDetect('JWT 인증 구현'); const b = _reuseDetect('날짜 포맷 date'); const c = _reuseDetect('전혀무관한xyzzy'); return a.some(x => x.key === 'auth') && b.some(x => x.key === 'date') && c.length === 0 && REUSE_CHECKLIST.length >= 5 && REUSE_CATEGORIES.length >= 12; } },
@@ -14562,20 +14581,31 @@ function releasePublish(root) {
14562
14581
  if (remote) log(`Git remote (origin): ${remote.host === 'github' ? `${remote.owner}/${remote.repo}` : remote.url}`);
14563
14582
  else log('Git remote: 없음');
14564
14583
 
14584
+ // 1.9.288 (Codex gpt-5.5 리뷰 #2 수렴): dry-run 은 모든 외부 side effect 를 계획 출력으로만 + 각 단계 status 실패 시 non-zero.
14585
+ let pubFail = false;
14565
14586
  // 2. npm pack (필요한 경우 — pack-only도 의미 있음)
14566
14587
  if (has('--pack') || has('--npm-publish') || (!has('--git-push') && !has('--gh-release') && !has('--gh-pages'))) {
14567
- const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
14568
- if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
14569
- ok('npm pack 완료');
14588
+ if (dryRun) { log('(dry-run) npm pack 생략 (실행 함)'); }
14589
+ else {
14590
+ const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
14591
+ if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
14592
+ ok('npm pack 완료');
14593
+ }
14570
14594
  }
14571
14595
 
14572
14596
  // 3. git push (--git-push 또는 --auto + remote 있을 때)
14573
14597
  if (has('--git-push') || (has('--auto') && remote)) {
14574
- log('git push:');
14575
- const r1 = cp.spawnSync('git', ['push'], { cwd: root, encoding: 'utf8', shell: true });
14576
- log((r1.stdout || r1.stderr || '').slice(-200) || '(no output)');
14577
- const r2 = cp.spawnSync('git', ['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
14578
- log((r2.stdout || r2.stderr || '').slice(-200) || '(no output)');
14598
+ if (dryRun) {
14599
+ log('(dry-run) git push / git push --tags 생략 (실행 )');
14600
+ } else {
14601
+ log('git push:');
14602
+ const r1 = cp.spawnSync('git', ['push'], { cwd: root, encoding: 'utf8', shell: true });
14603
+ log((r1.stdout || r1.stderr || '').slice(-200) || '(no output)');
14604
+ if (r1.status !== 0) { fail('git push 실패'); pubFail = true; }
14605
+ const r2 = cp.spawnSync('git', ['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
14606
+ log((r2.stdout || r2.stderr || '').slice(-200) || '(no output)');
14607
+ if (r2.status !== 0) { fail('git push --tags 실패'); pubFail = true; }
14608
+ }
14579
14609
  }
14580
14610
 
14581
14611
  // 4. GitHub Release (--gh-release, gh CLI 사용)
@@ -14584,6 +14614,7 @@ function releasePublish(root) {
14584
14614
  else {
14585
14615
  const v = getCurrentVersion(root);
14586
14616
  if (!v) { warn('--gh-release: package.json#version 없음 — 스킵'); }
14617
+ else if (dryRun) { log(`(dry-run) gh release create v${v} 생략 (실행 안 함)`); }
14587
14618
  else {
14588
14619
  const tag = `v${v}`;
14589
14620
  const ghArgs = ['release', 'create', tag, '--generate-notes', '--title', `${remote.repo} ${tag}`];
@@ -14600,8 +14631,11 @@ function releasePublish(root) {
14600
14631
 
14601
14632
  // 5. gh-pages 배포 (--gh-pages)
14602
14633
  if (has('--gh-pages')) {
14603
- const src = arg('--gh-pages-src', null) || arg('--roadmap', null) || 'roadmap.html';
14604
- deployGhPages(root, src);
14634
+ if (dryRun) { log('(dry-run) gh-pages 배포 생략 (실행 안 함)'); }
14635
+ else {
14636
+ const src = arg('--gh-pages-src', null) || arg('--roadmap', null) || 'roadmap.html';
14637
+ deployGhPages(root, src);
14638
+ }
14605
14639
  }
14606
14640
 
14607
14641
  // 6. npm publish (--npm-publish)
@@ -14614,7 +14648,9 @@ function releasePublish(root) {
14614
14648
  log((r.stdout || '').split('\n').slice(-5).join('\n'));
14615
14649
  if (r.status !== 0) { fail('npm publish 실패'); process.exitCode = 1; return; }
14616
14650
  }
14617
- ok('release publish 완료');
14651
+ // 1.9.288 (Codex #2): 단계 실패가 있으면 완료를 성공으로 출력하지 않고 non-zero 종료.
14652
+ if (pubFail) { fail('release publish: 일부 단계 실패 (위 로그 확인)'); process.exitCode = 1; return; }
14653
+ ok(`release publish 완료${dryRun ? ' (dry-run — 실제 변경 없음)' : ''}`);
14618
14654
  }
14619
14655
 
14620
14656
  // ===== 1.9.7 A: verify-code — npm scripts 자동 감지 + evidence 자동 기록 =====
@@ -16887,6 +16923,15 @@ function mcpServeCmd(root) {
16887
16923
  default:
16888
16924
  return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name}` } });
16889
16925
  }
16926
+ // 1.9.288 (Codex gpt-5.5 리뷰 #1 수렴): MCP 도구도 policy enforce 적용 — read-only enforce 시 write 도구 차단.
16927
+ // 이전: _policyEnforce 는 agents multi --execute 한 곳뿐 → MCP state_start 등이 정책 우회하고 .leerness 기록.
16928
+ // cliArgs(실제 실행 명령) 로 required tier 판정 → enforce ON 이고 초과 시 JSON-RPC error 반환(실행 안 함).
16929
+ try {
16930
+ const pol = _policyEnforce(targetPath, cliArgs.join(' '));
16931
+ if (!pol.allowed) {
16932
+ return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `정책 차단(policy): ${pol.reason}` }], isError: true } });
16933
+ }
16934
+ } catch {}
16890
16935
  const r = callLeerness(cliArgs);
16891
16936
  // 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
16892
16937
  const fullText = r.stdout || r.stderr || '(no output)';
@@ -17485,8 +17530,9 @@ async function _cliChatStream(root, provider, promptText, opts) {
17485
17530
  args = ['exec', '--skip-git-repo-check', '-']; // - = stdin from claude/codex convention
17486
17531
  useStdinForPrompt = true;
17487
17532
  }
17488
- else if (provider === 'agy') { cmd = 'agy'; args = ['-p', promptText]; } // agy -p 인자 모드만 지원
17489
- else if (provider === 'copilot') { cmd = 'gh'; args = ['copilot', 'suggest', promptText]; }
17533
+ // 1.9.289 (Codex #3): agy/copilot 인자 모드만 지원 shell:true 조인 프롬프트가 분리/주입될 있어 _shellQuoteArg 로 안전 인용.
17534
+ else if (provider === 'agy') { cmd = 'agy'; args = ['-p', _shellQuoteArg(promptText)]; } // agy -p 는 인자 모드만 지원
17535
+ else if (provider === 'copilot') { cmd = 'gh'; args = ['copilot', 'suggest', _shellQuoteArg(promptText)]; }
17490
17536
  else return { ok: false, error: `provider ${provider} 미지원`, provider };
17491
17537
  const t0 = Date.now();
17492
17538
  return new Promise(resolve => {
@@ -19976,9 +20022,8 @@ function healthCmd(root) {
19976
20022
  cap.replMultiProvider = (hasRepl && hasCliChat)
19977
20023
  ? { score: 90, status: '✓', evidence: 'ollama/claude/codex/agy/copilot 5종 (1.9.149+1.9.153)' }
19978
20024
  : { score: 30, status: '⚠', evidence: 'REPL 미완성' };
19979
- // (5) MCP 도구 — tools array 카운트
19980
- const toolsMatch = harnessSrc.match(/{ name: 'leerness_/g);
19981
- const toolCount = toolsMatch ? toolsMatch.length : 0;
20025
+ // (5) MCP 도구 — tools array 카운트 (1.9.288: 정확한 도구 정의 패턴 — 자기-매칭 오탐 제거, Codex #5)
20026
+ const toolCount = _mcpToolCount();
19982
20027
  cap.mcpTools = toolCount >= 50
19983
20028
  ? { score: 100, status: '✓', evidence: `${toolCount}/50+ 도구 (1.9.159 CRUD 완성)` }
19984
20029
  : { score: Math.round((toolCount / 50) * 100), status: toolCount > 30 ? '✓' : '⚠', evidence: `${toolCount} 도구` };
@@ -21611,5 +21656,9 @@ module.exports = {
21611
21656
  // 1.9.286: 스킬 영향 상관추적 (UR-0024) — 단위 테스트
21612
21657
  _parseEvidenceStats, skillImpactCmd,
21613
21658
  // 1.9.287: evidence 완전성 + 코드펜스 sanitize (Codex 리뷰 수렴) — 단위 테스트
21614
- _evidenceQuality, _sanitizeFences
21659
+ _evidenceQuality, _sanitizeFences,
21660
+ // 1.9.288: MCP 도구 수 단일 출처 (Codex #5) — 단위 테스트
21661
+ _mcpToolCount,
21662
+ // 1.9.289: shell-safe 인용 (Codex #3) — 단위 테스트
21663
+ _shellQuoteArg
21615
21664
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.287",
3
+ "version": "1.9.289",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -3042,5 +3042,59 @@ total++;
3042
3042
  if (!ok) { failed++; console.log((rFake.stdout || '').slice(-300)); }
3043
3043
  }
3044
3044
 
3045
+ // 1.9.288 회귀 (Codex gpt-5.5 리뷰 수렴): MCP policy enforce + release dry-run + 도구수 정합
3046
+ total++;
3047
+ {
3048
+ const env = { ...process.env, LEERNESS_OFFLINE: '1' };
3049
+ // #1 MCP policy: read-only enforce → state_start(write) 차단 + state.json 미생성
3050
+ const pDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mcppol-'));
3051
+ cp.spawnSync(process.execPath, [CLI, 'init', pDir, '--minimal', '--no-env', '--yes'], { encoding: 'utf8', timeout: 30000, env });
3052
+ cp.spawnSync(process.execPath, [CLI, 'policy', 'set', 'read-only', '--enforce', '--path', pDir], { encoding: 'utf8', timeout: 15000, env });
3053
+ const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_state_start', arguments: { path: pDir, goal: 'x' } } });
3054
+ const rMcp = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 15000, input: req + '\n', env });
3055
+ let blocked = false;
3056
+ try { const j = JSON.parse(rMcp.stdout.split('\n').filter(Boolean)[0]); blocked = j.result.isError === true && /정책 차단/.test(j.result.content[0].text); } catch {}
3057
+ const noWrite = !fs.existsSync(path.join(pDir, '.leerness', 'state.json'));
3058
+ // enforce 해제 시 통과(회귀 방지 — 정상 동작 보존)
3059
+ cp.spawnSync(process.execPath, [CLI, 'policy', 'set', 'project-write', '--no-enforce', '--path', pDir], { encoding: 'utf8', timeout: 15000, env });
3060
+ const rMcp2 = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 15000, input: req + '\n', env });
3061
+ let allowed = false;
3062
+ try { const j = JSON.parse(rMcp2.stdout.split('\n').filter(Boolean)[0]); allowed = j.result.isError !== true && /run-0001/.test(j.result.content[0].text); } catch {}
3063
+ // #2 release publish --dry-run --git-push: push 시도 안 함(생략 출력) + exit 0
3064
+ const rDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-reldry-'));
3065
+ fs.writeFileSync(path.join(rDir, 'package.json'), JSON.stringify({ name: 'x', version: '0.0.1' }) + '\n');
3066
+ const rRel = cp.spawnSync(process.execPath, [CLI, 'release', 'publish', rDir, '--dry-run', '--git-push'], { encoding: 'utf8', timeout: 20000, env });
3067
+ const dryOk = rRel.status === 0 && /\(dry-run\)/.test(rRel.stdout) && /생략/.test(rRel.stdout) && !/git push:/.test(rRel.stdout);
3068
+ // #5 도구수 정합: 배지 == 관리블록 == tools/list
3069
+ let countOk = false;
3070
+ try {
3071
+ const rl = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 10000, input: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + '\n', env });
3072
+ const live = JSON.parse(rl.stdout.split('\n').filter(Boolean)[0]).result.tools.length;
3073
+ const readme = fs.readFileSync(path.resolve(__dirname, '..', 'README.md'), 'utf8');
3074
+ const badge = (readme.match(/MCP--tools-(\d+)/) || [])[1];
3075
+ countOk = String(live) === badge;
3076
+ } catch {}
3077
+ const ok = blocked && noWrite && allowed && dryOk && countOk;
3078
+ console.log(ok ? '✓ B(1.9.288) Codex 수렴: MCP policy 차단/허용 + release dry-run 무push + 도구수 정합' : `✗ Codex 수렴 실패 (blocked=${blocked} noWrite=${noWrite} allowed=${allowed} dry=${dryOk} count=${countOk})`);
3079
+ if (!ok) { failed++; console.log((rRel.stdout || '').slice(0, 300)); }
3080
+ }
3081
+
3082
+ // 1.9.289 회귀 (Codex #3): _shellQuoteArg — REPL agy/copilot 프롬프트 셸 주입 중립화
3083
+ total++;
3084
+ {
3085
+ let ok = false;
3086
+ try {
3087
+ const h = require(path.resolve(__dirname, '..', 'bin', 'harness.js'));
3088
+ const q = h._shellQuoteArg('a; rm -rf / && echo $(whoami)');
3089
+ const win = process.platform === 'win32';
3090
+ // 따옴표로 감싸 메타문자가 단일 리터럴 인자가 됨 (POSIX 단일/Windows 이중)
3091
+ const wrapped = win ? (q.startsWith('"') && q.endsWith('"')) : (q.startsWith("'") && q.endsWith("'"));
3092
+ const neutral = win ? !/^[^"]*[;&|][^"]*$/.test(q.slice(1, -1)) || q.includes('"') : true;
3093
+ ok = wrapped && q.includes('rm -rf') && typeof h._shellQuoteArg === 'function';
3094
+ } catch {}
3095
+ console.log(ok ? '✓ B(1.9.289) _shellQuoteArg: 프롬프트 셸 주입 중립화 (agy/copilot args)' : '✗ _shellQuoteArg 실패');
3096
+ if (!ok) failed++;
3097
+ }
3098
+
3045
3099
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
3046
3100
  if (failed > 0) process.exit(1);