diff --git a/app/i18n.jsx b/app/i18n.jsx index 82dbd5af..b913954e 100644 --- a/app/i18n.jsx +++ b/app/i18n.jsx @@ -70,7 +70,7 @@ const DICT = { 'room.loadFail': ['회의 내용을 불러오지 못했습니다 — 잠시 후 자동으로 다시 시도합니다', "Couldn't load the meeting — retrying automatically"], 'runner.effortLabel': ['추론 강도', 'Reasoning effort'], 'runner.effortDefault': ['강도 기본', 'Default effort'], - 'runner.effortAutoHint': ['추론 강도 — Claude·Codex로 실행될 때 적용됩니다(Antigravity는 모델 선택에 강도가 포함 · Gemini·GLM·Kimi·OpenRouter·Grok은 미지원)', 'Reasoning effort — applies on Claude and Codex (Antigravity bakes effort into model choice; not supported on Gemini, GLM, Kimi, OpenRouter, Grok)'], + 'runner.effortAutoHint': ['추론 강도 — Claude·Codex·Kiro로 실행될 때 적용됩니다(Antigravity는 모델 선택에 강도가 포함 · Gemini·GLM·Kimi·OpenRouter·Grok은 미지원)', 'Reasoning effort — applies on Claude, Codex and Kiro (Antigravity bakes effort into model choice; not supported on Gemini, GLM, Kimi, OpenRouter, Grok)'], 'runner.effort.low': ['낮음 — 빠름', 'Low — fastest'], 'runner.effort.medium': ['보통', 'Medium'], 'runner.effort.high': ['높음', 'High'], @@ -289,7 +289,7 @@ const DICT = { 'settings.workroots.placeholder': ['폴더의 절대 경로', 'Absolute path to a folder'], 'settings.workroots.add': ['추가', 'Add'], 'settings.workroots.remove': ['해제', 'Remove'], - 'settings.workroots.runnerNote': ['Claude·GLM·Kimi·Grok 크루는 도구 게이트가 폴더 단위로 적용하고, Argo 앱·자격 폴더는 항상 차단합니다. Codex·Gemini·Antigravity 크루는 프로세스 단위 샌드박스에 같은 폴더 목록이 그대로 전달됩니다 — 넓은 폴더(예: 홈 전체)를 지정하면 그 안의 보호 폴더까지 함께 열릴 수 있으니 필요한 폴더만 좁게 지정하세요. 이 러너들은 지정하지 않은 폴더는 아예 읽지도 쓰지도 못하며, 구버전 Gemini CLI는 벤더 제한으로 지정 폴더 확장이 무시될 수 있습니다.', 'For Claude·GLM·Kimi·Grok crews the tool gate applies per folder and always blocks the Argo app and credential folders. Codex·Gemini·Antigravity crews receive the same folder list in their process-level sandbox — assigning a broad folder (e.g. your whole home) can open protected folders inside it too, so assign only what you need. These runners cannot read or write folders you have not assigned, and older Gemini CLI builds may ignore the expansion entirely (a vendor limit).'], + 'settings.workroots.runnerNote': ['Claude·GLM·Kimi·Grok 크루는 도구 게이트가 폴더 단위로 적용하고, Argo 앱·자격 폴더는 항상 차단합니다. Codex·Gemini·Antigravity 크루는 프로세스 단위 샌드박스에 같은 폴더 목록이 그대로 전달됩니다 — 넓은 폴더(예: 홈 전체)를 지정하면 그 안의 보호 폴더까지 함께 열릴 수 있으니 필요한 폴더만 좁게 지정하세요. 이 러너들은 지정하지 않은 폴더는 아예 읽지도 쓰지도 못하며, 구버전 Gemini CLI는 벤더 제한으로 지정 폴더 확장이 무시될 수 있습니다. Kiro 크루는 예외입니다 — 벤더 CLI에 폴더 반경을 강제할 수단이 없어 지정 폴더가 반경으로 적용되지 않습니다. Argo 앱 폴더, 러너 자격 폴더(~/.argo·~/.codex·~/.claude·~/.gemini 등), 다른 회사 데이터, 회사 금고(연결·능력·크루 카드·대화 정본)의 내용은 차단하지만(다만 폴더·파일 이름 목록은 보일 수 있습니다 — 벤더 제한), 그 밖의 이 컴퓨터 파일은 크루가 읽고 쓸 수 있습니다(새 파일·폴더 생성 포함). 또한 Kiro 크루에게는 터미널(셸)·검색 도구를 주지 않습니다 — 그 도구들에는 위 차단이 적용되지 않아 경계를 우회하기 때문입니다(파일 읽기·쓰기로만 일합니다).', 'For Claude·GLM·Kimi·Grok crews the tool gate applies per folder and always blocks the Argo app and credential folders. Codex·Gemini·Antigravity crews receive the same folder list in their process-level sandbox — assigning a broad folder (e.g. your whole home) can open protected folders inside it too, so assign only what you need. These runners cannot read or write folders you have not assigned, and older Gemini CLI builds may ignore the expansion entirely (a vendor limit). Kiro is the exception — its CLI offers no way to enforce a folder radius, so assigned folders are not applied as one. The Argo app folder, runner credential folders (~/.argo, ~/.codex, ~/.claude, ~/.gemini and the like), the contents of other companies’ data and this company’s vault (connections, capabilities, crew cards, chat records) are blocked (their folder and file names can still be listed — a vendor limit), but other files on this computer can be read and written by the crew (including creating new files and folders). Kiro crews are also given no terminal (shell) or search tools — those bypass the blocks above, so they work through file read/write only.'], 'settings.workroots.err.load': ['목록을 불러오지 못했습니다 — 새로고침 후 다시 시도해 주세요', 'Couldn\'t load the list — refresh and try again'], 'settings.workroots.err.invalid': ['경로를 확인해 주세요', 'Please check the path'], 'settings.workroots.err.not-absolute': ['절대 경로로 입력해 주세요 (예: /Users/me/projects, D:\\work)', 'Enter an absolute path (e.g. /Users/me/projects, D:\\work)'], @@ -364,7 +364,7 @@ const DICT = { 'settings.update.error': ['업데이트를 확인하지 못했어요. 잠시 후 다시 시도해 주세요.', 'Could not check for updates. Please try again shortly.'], // ── 러너 연결 관리자 (4러너 × API키/OAuth) 'settings.runners.title': ['러너 연결', 'Runner connections'], - 'settings.runners.help': ['크루의 두뇌 엔진을 회사 계정에 연결합니다. 러너마다 API 키·OAuth(구독)로 연결하거나, Codex·Gemini·Antigravity는 "이 컴퓨터 로그인 사용"으로 이 컴퓨터의 CLI 로그인을 연결할 수 있습니다(Antigravity는 자격이 OS 키링에 있어 이 방식이 유일합니다). 연결한 러너만 크루가 사용합니다.', 'Connect each crew brain engine to this company. Add an API key or OAuth (subscription) per runner — for Codex, Gemini and Antigravity you can also link this computer\u2019s CLI login (for Antigravity, whose credentials live in the OS keyring, this is the only path). Crews only use runners you connect.'], + 'settings.runners.help': ['크루의 두뇌 엔진을 회사 계정에 연결합니다. 러너마다 API 키·OAuth(구독)로 연결하거나, Codex·Gemini·Antigravity·Kiro는 "이 컴퓨터 로그인 사용"으로 이 컴퓨터의 CLI 로그인을 연결할 수 있습니다(Antigravity·Kiro는 붙여넣을 키가 없어 이 방식이 유일합니다). 연결한 러너만 크루가 사용합니다.', 'Connect each crew brain engine to this company. Add an API key or OAuth (subscription) per runner — for Codex, Gemini, Antigravity and Kiro you can also link this computer\u2019s CLI login (for Antigravity and Kiro, which have no pasteable key, this is the only path). Crews only use runners you connect.'], 'settings.runners.method.apikey': ['API 키', 'API key'], 'settings.runners.method.oauth': ['OAuth', 'OAuth'], 'settings.runners.method.host': ['이 컴퓨터 로그인', 'This computer\u2019s login'], @@ -1103,7 +1103,7 @@ const DICT = { 'chat.card.scopeStale': ['설치된 항목이 없는데 사용 범위가 남아 있습니다', 'nothing installed, but a scope is still set'], 'chat.card.scopeReset': ['전체 사용으로 복구', 'Reset to use-all'], 'chat.card.mcpCliWarn': ['이 크루의 러너(CLI)에서는 MCP 도구가 아직 실행되지 않습니다 — SDK 러너로 바꾸면 사용됩니다.', 'MCP tools do not run on this crew\'s (CLI) runner yet — switch to an SDK runner to use them.'], - 'market.mcpRunnerNote': ['CLI 러너(Codex·Gemini·Antigravity) 크루는 아직 MCP를 쓸 수 없습니다 — SDK 러너(Claude·GLM·Kimi·OpenRouter·Grok) 크루에서 사용됩니다. 러너 무관 지원이 예정돼 있습니다.', 'Crews on CLI runners (Codex·Gemini·Antigravity) cannot use MCP yet — it runs on SDK-runner crews (Claude·GLM·Kimi·OpenRouter·Grok). Runner-agnostic support is planned.'], + 'market.mcpRunnerNote': ['CLI 러너(Codex·Gemini·Antigravity·Kiro) 크루는 아직 MCP를 쓸 수 없습니다 — SDK 러너(Claude·GLM·Kimi·OpenRouter·Grok) 크루에서 사용됩니다. 러너 무관 지원이 예정돼 있습니다.', 'Crews on CLI runners (Codex·Gemini·Antigravity·Kiro) cannot use MCP yet — it runs on SDK-runner crews (Claude·GLM·Kimi·OpenRouter·Grok). Runner-agnostic support is planned.'], 'market.hostHint': ['설정(토큰 포함)이 그대로 복사되고, 기기 간 동기화 시에는 암호화되어 전송됩니다.', 'The config (including tokens) is copied as-is and encrypted when synced across devices.'], 'market.skillsmpSearchPlaceholder': ['수천 개 커뮤니티 스킬 검색 — 예: newsletter, seo, youtube', 'Search thousands of community skills — e.g. newsletter, seo, youtube'], 'market.mcpSearchPlaceholder': ['MCP 서버 검색 — 예: fetch, github, notion, slack', 'Search MCP servers — e.g. fetch, github, notion, slack'], diff --git a/app/runner-connect.jsx b/app/runner-connect.jsx index e1fe5b0e..a26cd345 100644 --- a/app/runner-connect.jsx +++ b/app/runner-connect.jsx @@ -21,11 +21,11 @@ export { anyRunnerUsable, runnerNeedsReconnect, usableRunnerNames, PICK_ORDER } /** AI 연결(러너별 BYOK/BYOA) — 4러너(Claude·Codex·Gemini·GLM) 각각을 회사 계정에 연결하는 관문. 러너마다 (a) 상태 칩(회사 연결됨/이 컴퓨터 로그인/미연결) (b) 인증 방식 선택(API키·OAuth) (c) 방식별 입력·저장·검증·제거 또는 CLI 로그인 안내. 응답엔 마스킹만 실린다(보안 규칙). */ -const RUNNER_NAMES = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', glm: 'GLM', kimi: 'Kimi', openrouter: 'OpenRouter', grok: 'Grok' }; +const RUNNER_NAMES = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', kiro: 'Kiro', glm: 'GLM', kimi: 'Kimi', openrouter: 'OpenRouter', grok: 'Grok' }; // 화면에 그릴 순서 — **이 목록에 없으면 카드가 아예 안 뜬다**(러너를 추가하고 여기를 빠뜨리면 // 연결 수단이 UI에서 사라진다. 분리 검수 2026-08-03이 grok 누락으로 실제 적발). // test/runner-order-sync.test.mjs가 RUNNER_AUTH와의 동기화를 잠근다. -const RUNNER_ORDER = ['claude', 'codex', 'gemini', 'antigravity', 'glm', 'kimi', 'openrouter', 'grok']; +const RUNNER_ORDER = ['claude', 'codex', 'gemini', 'antigravity', 'kiro', 'glm', 'kimi', 'openrouter', 'grok']; export function AiConnectionCard({ ws, accordion = false }) { const { t } = useLang(); diff --git a/app/runner-usable.mjs b/app/runner-usable.mjs index e636b227..ea19479d 100644 --- a/app/runner-usable.mjs +++ b/app/runner-usable.mjs @@ -19,7 +19,7 @@ export function runnerNeedsReconnect(runners) { /** 서버 자동 선택(pickRunner = RUNNER_AUTH 정의 순) 순서 — "자동" 표시가 실제 실행 러너와 어긋나지 않게. 카탈로그(/api/runners = RUNNERS 순)는 kimi·glm 순서가 달라 첫 authed를 그냥 집으면 오표시가 난다. */ -export const PICK_ORDER = ['claude', 'codex', 'gemini', 'glm', 'kimi', 'openrouter', 'grok', 'antigravity']; +export const PICK_ORDER = ['claude', 'codex', 'gemini', 'glm', 'kimi', 'openrouter', 'grok', 'kiro', 'antigravity']; /** 연결(유효)된 러너의 표시 이름 목록 — 명판 '엔진' 표기의 단일 진실. 'Claude Agent SDK' 하드코딩이 Gemini만 연결한 사용자에게 "클로드로 떠 있다" 혼란을 준 diff --git a/docs/kiro-runner-design.md b/docs/kiro-runner-design.md new file mode 100644 index 00000000..865704ce --- /dev/null +++ b/docs/kiro-runner-design.md @@ -0,0 +1,370 @@ +# Kiro 러너 — BYOA 3호 설계 (2026-08-12) + +## 왜 + +Kiro CLI(`kiro-cli`)는 **AWS 계정 하나로 여러 벤더 모델을 함께 쓴다** — 실측 카탈로그에 Claude +(Opus 5·Sonnet 5·Haiku 4.5), GPT-5.6(Sol·Terra·Luna), GLM-5, DeepSeek V3.2, MiniMax M2.5, +Qwen3 Coder Next가 한 자격 아래 있다. 지금 Argo에서 같은 폭을 얻으려면 러너를 여럿 연결해야 하고, +회사 구독으로 Kiro를 쓰는 사용자는 그 구독을 Argo에 태울 경로가 아예 없다. + +목적: **IAM Identity Center / Builder ID 구독 사용자가 자기 자격으로 Argo를 쓰게 한다.** + +## 분류 — BYOA (CLI 래핑 + 권한 근사 정직 표기) + +러너 구조 이원화 규칙 그대로다. 구독을 태우는 길은 CLI 래핑뿐이고, 세 번째 배관을 만들지 않는다. +`kiro-cli`의 자격은 CLI 로그인이고 토큰은 로컬 데이터 저장소(macOS +`~/Library/Application Support/kiro-cli`)에 있어 붙여넣을 API 키 표면이 없다 — codex·antigravity와 +같은 **host 옵트인 전용**이다. + +## 실측 기반 (kiro-cli 2.17.0, macOS 26, 2026-08-12) + +| 항목 | 실측 | 함의 | +|---|---|---| +| 원샷 | `chat --no-interactive [--model] [--effort] --wrap never -- ` exit 0 | codex/gemini 선례 그대로 래핑. `--`가 있어 `---`로 시작하는 카드 frontmatter 안전 | +| 데몬 스폰 | TTY 없음 · stdin 닫힘 · 최소 env에서도 exit 0 | 상주(launchd)·헤드리스 경로에서 동작 | +| 모델 | `chat --list-models --format json` → 19종. 그중 10종 원샷 왕복 **10/10 통과** | 카탈로그 규칙("실행 경로 실턴 통과 id만") 충족 | +| 추론 강도 | `--effort low\|medium\|high\|xhigh\|max` | Argo `effort`와 1:1 — codex와 같은 자리 | +| 자격 판정 | `whoami` → 로그인 `Logged in with IAM Identity Center`(exit 0) / 미로그인 `Not logged in`(exit 1) | **파일이 아니라 CLI에 직접 묻는다** → antigravity와 달리 낙관 authed가 아니다 | +| 미로그인 턴 | 비대화에서도 브라우저를 열려 시도 → `error: OAuth error: Auth portal timed out`(exit 1) | apiError 매핑으로 로그인 안내 번역(+ AUTH_ERR_RE 계약) | +| 설정 격리 | `/.kiro/agents/.json` + `--agent `. `mcpServers:{}` + `useLegacyMcpJson:false` | 사용자 전역 MCP 서버 경고·의도 밖 도구 소멸(실측) | +| 최종 답변 | 어시스턴트 메시지 **첫 줄에만** `> ` 접두사. 도구 추적은 무접두사 | 마지막 `> ` 블록 = codex `--output-last-message` 등가 | +| 마크다운 인용 | 모델이 `> quoted`를 내면 렌더 결과는 `│ quoted` | 접두사와 충돌하지 않는다 | + +### 파일 반경 강제 — 네 수단 중 하나만 산다 + +`toolsSettings`에 경로 규칙이 있다는 공식 문서를 그대로 믿지 않고 넷을 다 돌렸다(레포 규칙: +문서만 보고 추가 금지). 비대화(`--no-interactive`) 기준 실측이다. + +| 수단 | 실측 결과 | +|---|---| +| `tools` 목록에서 제외 | **하드** — 도구 자체가 없다 | +| `allowedTools` | 포괄 자동 승인. 비대화에서 **필수** — 없으면 반경 안쪽 쓰기까지 전부 거부됐다 | +| `toolsSettings.*.allowedPaths` | **효력 없음** — 자동 승인을 주지 못해 안쪽까지 거부. `allowedTools`를 함께 주면 포괄 신뢰가 경로 규칙을 덮는다(문서 신뢰 우선순위 4 > 5와 일치) | +| `toolsSettings.*.denyByDefault` | **무시됨**(shell 전용). write/read에 넣어도 반경 밖 쓰기가 성공 | +| deny 글롭 부정 패턴(`!`) | **미지원** — `["$HOME/**", "!/**"]`은 carve-out 없이 전부 차단 | +| `toolsSettings.*.deniedPaths` | **하드 차단. `allowedTools`보다 우선**(파일 미생성 + 크루가 "blocked by safety constraints" 보고) | + +결론: **화이트리스트 의미를 표현할 수 없다.** `openRoots`(홈+지정 폴더)를 인자로 실을 자리가 없다. + +그리고 강제되는 그 하나에도 함정이 있다 — **kiro-cli는 canonical 경로로 deny를 판정한다.** raw 경로만 +실으면 심링크 경유 ARGO_ROOT(맥의 `/tmp`·`/var`가 심링크이고, 외장 볼륨·동기화 폴더 경유도 흔하다)에서 +**경계 전체가 조용히 열린다**. 분리 검수가 라이브로 재현했다: 형제 회사 파일과 WS_ROOT 직속 도트를 +둘 다 읽었고, 심링크 없는 실경로에서는 같은 프롬프트가 전부 막혔다(대조군). 공격 행위가 필요 없고 +**설정만으로** 열리는 계열이라, raw·canonical 두 형태를 모두 싣는다(`permission-gate.mjs`가 하드 구역에 +두 형태를 담는 것과 같은 이유·같은 방식). + +## 구현 결정 + +- **집행 = 불변 경계 deny.** `openRoots`를 흉내내지 않고, "지정으로도 열리지 않는 금지 구역"만 + `deniedPaths`로 옮긴다. 목록은 `permission-gate.mjs`와 **같은 값**이다 — 같은 파일을 SDK 러너는 + deny하고 kiro는 allow하면 러너에 따라 자격 유출 여부가 갈린다(그 파일의 "도구별 판정이 갈린다" + 원칙과 같은 클래스). `test/kiro-runner.test.mjs`가 두 목록의 정합을 소스 파싱으로 잠근다. + - APP_ROOT — 실행 중인 Argo 코드 + - 홈 직속 자격 — `HARD_HOME_PATHS`(`~/.argo`·`~/.codex`·`~/.claude`·`~/.gemini`·`~/.claude.json`·`~/.mcp.json`) + - WS_ROOT의 **형제 회사**(교차 테넌트) + WS_ROOT 직속 도트(계정 시크릿·기기 마커) + - 회사 금고 — `WS_CONTROL_FILES`(capabilities·mcp·connections·company·routines·approvals·gw-cursor) + + `WS_CONTROL_DIRS`(agents·chats) + `` 직속 도트(`.workroots.json`, 이 러너의 `.kiro` 포함) + - 원장(`usage.jsonl`·`events.jsonl`)은 **쓰기만** 막는다 — permission-gate와 같은 근거(사장이 + "이번 달 얼마 썼어?"라고 물었을 때 답할 수단을 없애는 것은 자가 승격 차단과 무관한 기능 후퇴다) + - 경계는 `cwd`에서 도출한다(`dirname(cwd)` = WS_ROOT — permission-gate와 같은 계산, 새 임포트 0). + WS_ROOT를 통째로 deny하면 자기 회사(크루 책상)까지 막히므로 형제만 열거한다(readdir 1회). + - 모든 글롭 루트는 **raw + canonical 양형**(위 함정). +- **`caps.fs` 반경 차이는 UI 정직 표기.** 지정 폴더가 반경으로 적용되지 않는다는 사실을 + `i18n settings.workroots.runnerNote`가 ko·en 양쪽에 명시하고, `test/kiro-runner.test.mjs`가 + 그 표기를 잠근다. 러너별 집행 강도 차이를 화면이 정직하게 말하는 방식은 이 레포가 이미 채택했다 + (`workroots.mjs` 러너별 집행 절 — 그 주석에 kiro 항목을 추가했다). +- **도구는 경계가 증명된 것만.** `read`·`write`(+브라우저 능력 시 `web_fetch`)뿐이다. `grep`·`glob`· + `shell`은 `deniedPaths`가 적용되지 않아 경계를 통째로 우회한다(한계 4번 실측) — 설정으로 막을 수 + 없으니 **도구 자체를 주지 않는다**. `caps.shell`이 켜져 있어도 셸을 주지 않으며, UI가 그 사실을 + ko·en 양쪽에 명시한다. +- **`allowedTools` = `tools`.** 비대화에서 포괄 신뢰가 없으면 도구가 전부 거부되므로 선택이 아니다. + 경로 강제는 `deniedPaths`가 한다(포괄 신뢰보다 우선). +- **경계 생성 실패는 fail-closed.** `HOME`/`USERPROFILE`이 없으면 홈 자격 경계를 만들 수 없으므로 + 턴을 돌리지 않고 throw한다. 조용히 빠지면 그 경계가 경고도 없이 사라진다(실측: 홈 항목 0건) — + `deniedPaths`가 집행의 전부인 러너에서는 그게 곧 경계 소멸이고, launchd·Tauri 최소 env가 정확히 + 이 조건이다(분리 검수 2라운드 MEDIUM). +- **턴별 에이전트 설정 + 고유 이름.** `/.kiro/agents/argo-.json`을 매 턴 쓰고 + `finally`에서 지운다(빈 디렉터리까지 `rmdir` — 비어 있지 않으면 실패하므로 동시 턴의 설정이나 + 사용자 `.kiro` 내용물을 지울 위험이 없다). 고정 이름이면 같은 회사에서 두 크루가 동시에 답할 때 + 한쪽 `finally`가 다른 쪽의 실행 중 설정을 지운다. 이 경로 자체도 deny 목록에 들어간다. +- **감지는 `whoami`.** 파일 존재로는 판정할 수 없다(토큰이 데이터 저장소·OS 보관). + ⚠ 정규식은 **줄머리 앵커가 필수**다: codex가 쓰는 `/Logged in/i`는 `"Not logged in"`에도 + 매칭돼 미로그인을 '연결됨'으로 뒤집는다. `/^Logged in/im`을 쓴다(테스트가 이 함정을 잠근다). + 비용 ~1.3초지만 `detectRunners`의 `Promise.all` 안이고 상위 캐시가 10분이라 체감 없다. +- **`connect`(로그인 대행) 미배선.** `kiro-cli login`이 라이선스 종류(free/pro)·IdP URL을 되묻는 + 대화형일 수 있어(미검증) detached spawn이 조용히 멈출 위험이 있다 — 터미널 로그인 안내가 정직하다. +- **자동 조달 없음.** 공식 인스톨러가 셸 프로파일을 수정하고 데스크톱 앱까지 함께 설치한다 + (antigravity와 같은 판단). PATH 설치본 → `~/.local/bin/kiro-cli` 폴백까지만. +- **RUNNER_AUTH 배치는 grok 뒤 · antigravity 앞.** antigravity가 맨 끝인 이유는 낙관 authed가 + 검증된 자격을 선점하지 못하게 하는 것인데(분리 검수 H1a), kiro는 authed가 실측값이라 그 문제가 없다. + +## 검증 (실측 — 라이브) + +- **게이트**: `npm run lint` 통과. `npm test` **911 tests / 906 pass / 0 fail / 5 skipped** + (기존 883에서 +28 — `test/kiro-runner.test.mjs`. 공용 파서(`cli-directives.mjs`) 변경 회귀도 이 파일이 잠근다). +- **E2E**(`node scripts/e2e-kiro.mjs`, 격리 `ARGO_ROOT` + 포트 3164 + Supabase env 제거): + 카탈로그(kind=cli·10모델·installed) → 상태(`hostAuthed=true` 실측·`cli=true`·`authUnknown` 없음) + → host 옵트인 → 회사 생성 → **크루 영입 턴**(실 LLM) → **채팅 턴**(`kiro-ok`) → + **턴 잔재 0** → **경계 집행** → **책상 정상**까지 전부 통과. +- **경계 집행 판정 방법이 이 검증의 본체다.** 첫 구현은 "답변에 시크릿이 없으면 통과"였는데 그건 + **집행으로 막힌 것과 모델이 스스로 거절한 것을 구분하지 못한다** — 분리 검수가 같은 형상에서 + 중립 프롬프트로 유출을 재현해 그 판정이 위양성임을 증명했다. 지금은 ① 프롬프트를 중립으로 두고 + ② **도구 레벨 거부 문자열**(`rejected because it matches one or more rules on the denied list`)을 + 집행 근거로 요구하며 ③ 센티널 3종(형제 회사·WS_ROOT 직속 도트·회사 금고 `connections.json`)의 + 미유출을 함께 본다. 심링크 경유 WS_ROOT에서도 별도 재현으로 통과를 확인했다(canonical 양형 수정 후). +- **과차단 대칭 검증**: 같은 설정으로 크루가 자기 회사 폴더(`vault/notes/`)에 읽기·쓰기를 정상 + 수행한다 — 경계가 크루의 일을 막지 않는다. +- 모델 10종 원샷 왕복 10/10(위 표). + +## 미검증 · 알려진 한계 (정직 표기 — 발행 전 확인 항목) + +1. **답변 절단(실증된 결함).** 최종 답변 **2행 이후**의 코드블록에 `> `로 시작하는 줄이 있으면 + 그 줄을 새 메시지 시작으로 오인해 앞부분이 절단된다(재현: `Here is the snippet:` + + ```` ``` ````/`> git status` → 앞 문장 유실). 렌더 결과에 코드펜스가 남지 않아 "코드블록 안"을 + 판별할 수단이 없다. **생산 경로에서 라이브 재현됨**(분리 검수 3라운드): 배포 절차 안내 답변에서 앞 문장과 코드펜스가 통째로 사라지고 명령 줄만 남았다. 뿌리는 아래 지시 블록 문제와 같다(렌더러의 코드펜스 제거) — 셸·git 출력을 보여주는 흔한 코딩 턴에서 그냥 발현한다. `scripts/e2e-kiro.mjs`는 센티널 내용 유출만 보므로 이 계열을 구조적으로 잡지 못한다. + → **근본 해법은 `kiro-cli acp`**(Agent Client Protocol, JSON-RPC over stdio): 메시지가 구조화돼 + 파싱이 사라지고, 도구 호출을 우리가 승인·거절하므로 아래 2·3번도 함께 닫힌다. 별도 PR 권장. +2. **`caps.fs` 반경 미강제**(설계상 수용 + UI 표기). 사장이 지정 폴더를 좁게 걸어도 이 러너에서는 + 반경이 되지 않는다. 불변 경계 밖의 이 컴퓨터 파일은 크루가 **읽고 쓸 수 있다** — 새 파일·폴더 생성도 된다(실측: 사용자 Documents에 파일 생성, WS_ROOT 안에 새 회사 폴더 트리 생성 성공. 부모 디렉터리가 자동 생성된다). +3. **셸·검색 도구를 주지 않는다(능력 차이).** `deniedPaths`는 `read`·`write`에만 서고 다른 도구는 + 같은 파일을 그냥 연다 — 그래서 `grep`·`glob`·`shell`을 도구 목록에서 뺐다(아래 4번 실측). + 결과적으로 **Kiro 크루는 파일 읽기·쓰기로만 일한다** — 같은 지시가 codex 크루는 되고 kiro 크루는 + 안 되는 경우가 생긴다(러너 중립성의 제약 방향). 셸 부재 자체는 선례가 있다: gemini CLI 러너도 + 비대화(`--approval-mode auto_edit`)에서 셸이 실행되지 않는 상태로 운영된다. + 여는 조건은 명확하다 — `kiro-cli acp`로 옮겨 도구 호출을 우리가 승인·거절하게 되면 세 도구 모두 + SDK 러너와 같은 강도로 복원된다. +4. **`deniedPaths`는 read·write 전용이다(실측 — 이 러너 설계의 근간).** 분리 검수 2라운드가 라이브로 + 증명했다. 같은 턴·같은 설정에서: + - `grep` → 회사 금고 `connections.json`·형제 회사 파일·WS_ROOT 직속 도트의 **매치 줄 내용을 그대로 + 반환**. `read`로 같은 파일을 열면 정상 DENY(대조군). + - `glob` → `~/.codex` 7,766개·`~/.claude` 527개 파일 열거(경로 유출). + - `shell` → `cat <금고>`·`cat ../<타사>/notes.md` 유출, `echo > usage.jsonl`로 **원장이 디스크에서 + 실제로 개조됨**. + `grep`·`glob`은 `toolsSettings` 키가 공식 스키마에 없어 막을 수도 없다(미지 키는 조용히 무시된다). + `shell`은 `deniedCommands`(정규식)가 강제되긴 하지만(실측), 명령 문자열 정규식은 `$(…)`·`sh -c`· + 인터프리터 경유로 우회가 자유롭고 permission-gate처럼 **인자 경로를 판정하는 백스톱이 없다**. + `denyByDefault`는 문서에 있으나 무시된다(실측: 화이트리스트 밖 명령이 그대로 실행됨). + → 설정으로 막을 수 없으면 **도구를 주지 않는다**가 이 러너의 fail-closed다. +5. **`read` Directory 모드 재귀 열거가 금지 구역의 경로를 노출한다(설정으로 막을 수 없다).** + deny 판정이 **요청 경로에만** 걸리고 재귀 산출 항목에는 걸리지 않는다 — WS_ROOT를 depth 3으로 + 요청하면 형제 회사·회사 금고·직속 도트의 **경로가 전부 열거**된다(내용은 보호된다). + 2라운드가 CRITICAL로 잡은 glob 열거와 같은 계열이 `read` leg로 남은 것이다. + 막을 지점은 열거의 진입점(조상 사슬)인데 **불가능하다**: kiro-cli의 deny는 정확 경로가 아니라 + **디렉터리 포함** 판정이라(실측) WS_ROOT를 막으면 자기 회사 폴더까지 죽는다(`/`를 넣으면 전부 차단). + 즉 구조 유출은 `acp` 이관 전까지 수용해야 하는 한계다. 영향 범위: 다른 회사의 **이름과 파일 구조**가 + 보인다(자격·본문은 보호). 단일 사용자 로컬 제품이라는 전제에서 수용하되, 이 한계를 모르고 + "타사 데이터가 완전히 격리된다"고 읽히지 않도록 여기 명시한다. +6. **하드링크는 경로 기반 deny를 우회한다.** 금고 파일의 하드링크를 회사 폴더 안에 두면 읽힌다 + (inode 동일성을 볼 수단이 없다). 생성에는 셸이 필요해 위 3번으로 대부분 포섭되지만, 이미 존재하는 + 하드링크는 파일 도구만으로도 통과한다 — 구조적 한계로 수용. +7. **답변 속 kiro식 번호 diff는 지워진다(의도된 트레이드오프).** 추적 줄 제거를 항상 적용하므로, + 크루 답변이 `+`+공백 2칸 이상+숫자+`:` 형태를 본문으로 담으면 그 줄이 함께 사라진다. 이 규칙은 + `fs_write`가 **거부 전에** 기존 파일 내용을 렌더하는 자리라 유출 위험이 가장 크고, 그걸 살리는 + 대가로 좁은 산문 손실을 받아들였다. 일반 목록(`- 1: …`)·공백 1칸 형태는 규칙 밖이다. +8. **`fs_read` 배치 원자성.** deny 대상 1개가 배치에 섞이면 그 배치의 허용 대상까지 함께 거부된다 + (실측: 15건 배치 전부 실패). 보안 문제는 아니지만 크루가 "왜 못 읽었는지"를 오설명하는 자리다. +9. **전역 기본 리소스 상속.** 커스텀 에이전트는 전역/워크스페이스 steering·skills·`AGENTS.md`를 + 자동 상속한다(공식 문서). 끄는 스위치가 전역 설정(`chat.disableInheritingDefaultResources`)뿐이라 + 에이전트 설정으로는 막을 수 없다. 즉 **사용자 개인 kiro 설정이 크루 턴 맥락에 섞일 수 있다**. + **실측 확인됨**(분리 검수 3라운드): 크루 턴이 사용자 개인 `~/.kiro/skills/*` 12종의 이름과 경로를 정확히 열거했다. 같은 사용자 파일이라 교차 테넌트는 아니지만, Argo가 통제하지 않는 텍스트가 매 턴 크루 맥락에 주입되는 **프롬프트 인젝션 표면**이다. `mcpServers:{}`는 MCP만 닫고 steering·skills는 못 닫는다. +10. **Windows 전반.** `%LOCALAPPDATA%\kiro-cli\bin\kiro-cli.exe` 경로 후보는 실기기 미검증이고, + `deniedPaths` 글롭이 백슬래시 경로에서 매칭되는지도 미검증이다. 후자가 실패하면 경계가 조용히 + 열린다 — Windows 실기기 확인 전에는 그 환경 발행을 보류할 것. +11. **상주(launchd)·데스크톱 번들에서 자격 접근.** 데몬 유사 환경(TTY·stdin·최소 env)은 실측했으나, + 실제 launchd 서비스와 Tauri 사이드카에서 `kiro-cli`가 자기 토큰 저장소를 여는지는 미확인 + (claude 재서명 node 키체인 차단 전례가 있는 클래스). +12. **형제 회사 열거 TOCTOU.** 설정을 쓴 뒤 그 턴 중에 새 회사가 생기면 그 폴더는 이 턴에 deny되지 + 않는다. 회사 생성은 사장의 UI 행위라 턴 중 발생이 비현실적이고 다음 턴엔 잡힌다 — 수용. +13. **`login --use-device-flow`.** 존재는 확인했으나 배관은 붙이지 않았다. grok의 `deviceCode` + 패턴을 그대로 쓸 수 있어(상주·헤드리스에서 루프백 리스너 없이 로그인) 후속 후보. +14. **비용 표면.** kiro-cli는 턴마다 크레딧을 소비한다(실측: 한 줄 응답 ≈ 0.02~0.03). 자격 타입이 + `host`라 Argo 청구 판정은 "구독 안"으로 잡히는데(`billedByType`) 이는 API 키 과금이 아니라는 + 뜻이지 무료라는 뜻이 아니다. 크레딧 잔량 소진 시그니처는 미실측 — OpenRouter 402·Grok 크레딧 + 처럼 별도 안내가 필요할 수 있다. + +## 분리 검수 반영 (2026-08-12, REJECT → 수정) + +구현과 다른 컨텍스트의 검수가 CRITICAL 2건을 **라이브 재현으로** 잡았다. 둘 다 "경계가 있다고 +믿었는데 실제로는 서지 않는" 계열이라 그대로 발행하면 교차 테넌트 유출이 됐다. + +- **CRITICAL-1 — deny 글롭이 raw 경로였다.** kiro-cli는 canonical로 판정한다. 심링크 경유 + ARGO_ROOT에서 형제 회사 파일과 WS_ROOT 직속 도트를 둘 다 읽는 것이 재현됐고, 심링크 없는 + 실경로가 대조군으로 전부 막혔다(메커니즘 자체는 정상). → 모든 글롭 루트에 raw·canonical + 양형을 싣고, `test/kiro-runner.test.mjs`가 심링크 트리로 이를 잠근다. +- **CRITICAL-2 — E2E 경계 검증이 위양성이었다.** 답변 문자열만 봤고 프롬프트가 모델의 자발적 + 거절을 유도해서, 경계가 통째로 열려 있어도 초록이 떴다(검수자가 같은 형상에서 중립 프롬프트로 + 유출을 재현). → 판정을 도구 레벨 거부 문자열로 옮기고 프롬프트를 중립화. 설계 문서의 + "경계 집행은 실측이다" 주장도 그 근거를 명시하도록 고쳤다. +- **MEDIUM — 화면이 "자격 폴더 차단"이라 말하는데 `~/.argo`만 막고 있었다.** permission-gate가 + 능력·bypass와 무관하게 하드 차단하는 `HARD_HOME_PATHS`(`~/.codex`·`~/.claude`·`~/.gemini`· + `~/.claude.json`·`~/.mcp.json`)가 빠졌다. → 목록을 permission-gate와 동일하게 채우고, 소스 파싱 + 대조 테스트로 두 목록의 정합을 잠갔다. 검수 지적대로 문구도 실제 차단 대상을 나열하도록 고쳤다. +- **MEDIUM — 회사 금고를 도트 항목과 동일시했다.** 실제 금고(`connections.json`·`capabilities.json`· + `agents/`·`chats/` 등)는 도트가 아니라 `/.*` 글롭으로 안 잡혔다. → `WS_CONTROL_FILES`· + `WS_CONTROL_DIRS`를 deny에 추가(원장은 permission-gate와 같이 쓰기만). +- **MEDIUM — i18n 영어 문구의 이중 이스케이프**로 화면에 `\u2019`가 그대로 노출됐다. 하필 보안 한계를 + 알리는 문장이었다. → 실제 문자로 수정. +- **LOW 반영**: 빈 `.kiro/agents` 디렉터리까지 정리(`rmdir` — 비어 있지 않으면 실패하므로 동시 턴 + 안전) · caps→도구 깎기가 채팅 경로에서 발현하지 않는다는 사실을 구현 결정에 명시 · grep/glob의 + 벤더 의존을 한계 4번으로 승격. + +검수가 확인하지 못한 것도 그쪽 보고 그대로 남긴다: 모델 10종 재현(크레딧 소모로 미재현 — 검수자는 +`claude-haiku-4.5` 1종만 실턴 확인), `kiroScrub` 절단 결함 라이브 재현(코드 판독만), Windows 전반, +상주·Tauri 자격 접근, 동시 턴 실행(코드 판독으로는 경합 없음). + +## 분리 검수 2라운드 (2026-08-12, REJECT → 수정) + +1라운드 수정을 재검한 2라운드가 **1라운드가 닫은 것은 read/write leg뿐**임을 증명했다. +같은 계열 갭이 한 번에 닫히지 않는다는 레포 경험(경계 게이트 갭이 3라운드 만에 닫힘)의 재현이다. + +- **CRITICAL — `grep`·`glob`이 경계를 통째로 우회한다(능력 0으로, 매 턴).** 셸 OFF·기본 caps 턴에서 + grep이 금고 `connections.json`·형제 회사·WS_ROOT 도트의 매치 줄을 그대로 반환했고, glob이 `~/.codex` + 7,766개·`~/.claude` 527개를 열거했다. 같은 턴의 `read`는 정상 DENY(대조군). 두 도구는 caps와 무관하게 + 항상 부여돼 있었으므로 어떤 능력도 필요 없는 우회였다. + 더 나쁜 것은 표기였다 — 소스 주석과 한계 절이 "실측상 grep은 read 규칙을 받아 차단된다"고 **반대로** + 적혀 있었다. 1라운드 검수자의 미검증 관찰(그쪽도 "벤더 내부 동작 의존"이라 단서를 달았다)을 구현자가 + 사실로 옮긴 결과다. 1라운드가 잡은 "경계가 있다고 믿었는데 서지 않는" 계열의 재발이다. + → `BASE_TOOLS`에서 `grep`·`glob` 제거. 직접 재현으로 지적을 확인한 뒤 수정했고, 수정 후 재검 + 실턴에서 형제 회사·금고 유출이 모두 사라졌다(책상 읽기·쓰기는 정상). +- **HIGH — 셸 능력 ON이면 경계가 완전히 사라진다.** `cat <금고>`·`cat ../<타사>` 유출, `echo > + usage.jsonl`로 원장이 디스크에서 개조됨. `deniedCommands`는 강제되지만(실측) 명령 문자열 정규식은 + 우회가 자유롭고 인자 경로 백스톱이 없다. `denyByDefault`는 무시된다(실측). + → `caps.shell`을 무시하고 셸을 주지 않는다(fail-closed). gemini도 비대화에서 셸이 없다는 선례를 + 근거로 삼되, 능력 차이가 생긴다는 사실을 UI에 명시했다. +- **MEDIUM — `homeDir()` 부재 시 홈 자격 경계가 조용히 사라졌다**(fail-open). → throw로 전환. +- **MEDIUM — 하드링크 우회**, **LOW — `fs_read` 배치 원자성**, **LOW — 한계 절 번호 중복** → 한계 절 반영. +- 2라운드가 **닫혔다고 확인한 1라운드 지적**: 심링크(양형, 우회 벡터 9종 추가 시도 전부 DENY — + 상대경로·대소문자 변종·후행 점·회사 안 심링크 포함) · E2E 판정(경계 부분 무능화 3형상 대조에서 + 전부 실패 확인 = 위양성 아님) · 하드홈 6=6 · 금고 7=7·2=2·2=2 · 정합 테스트 red 검증 4/4 · + i18n 이스케이프 · 빈 디렉터리 정리. 과차단 회귀는 발견되지 않았다(원장 읽기·`vault/` 아래 동명 + 파일·`agents` 접두 파일 전부 허용 확인). + +교훈 하나를 남긴다: **다른 검수자의 미검증 관찰을 내 문서에 사실로 옮기지 말 것.** 1라운드가 +"벤더 내부 동작 의존"이라 단서를 달았는데도 그걸 "차단된다"로 적었고, 그 한 줄이 CRITICAL을 가렸다. + +## 분리 검수 3라운드 (2026-08-12, REJECT → 수정) + +2라운드 수정이 우회 도구를 배제한 뒤, 3라운드는 **남은 두 도구가 새 우회로가 되는지**와 **크루가 +실제로 일할 수 있는지**를 봤다. 보안 우회 하나와 기능 결손 둘이 나왔다. + +- **HIGH — `read` Directory 모드 재귀 열거로 금지 구역 경로가 유출된다.** 조상 사슬 deny로 진입점을 + 봉인하려 시도했으나 **불가능**했다: kiro-cli의 deny는 정확 경로가 아니라 디렉터리 포함 판정이라 + WS_ROOT를 막으면 크루 책상까지 죽는다(실측: `/` 한 줄로 전부 차단, 자기 회사 파일 읽기도 거부됨). + 시도했다가 되돌렸고, 그 사실과 함께 한계 5번으로 남겼다. 내용은 보호되고 구조만 유출된다. +- **HIGH — ```argo 지시 관행이 성립하지 않았다(러너 중립성 위반).** kiro-cli는 렌더 결과에서 + 코드펜스를 제거한다 — `BLOCK_RE`가 영원히 매치되지 않아 루틴 예약·쪽지·결재·커넥터가 **조용히 + 무동작**하고 원시 JSON이 사장 화면에 남았다. `cli-directives.mjs`가 없애려 만들어진 바로 그 증상 + ("크루가 예약했다고 말만 한다")의 러너별 재발이다. + → 펜스 없는 변종(`^argo\n{…}`)을 파서에 추가했다. 오탐 방지로 **`"action"` 키가 있을 때만** 지시로 + 보고, 없으면 원문을 그대로 남긴다(산문 손실 0). 두 형태의 파싱 결과 동일성을 테스트가 잠근다. +- **HIGH — 크루 프롬프트가 매 턴 거짓을 말했다.** `caps`는 전권 상수(`capabilities.mjs`)라 + `caps.shell`로는 판정할 수 없어 kiro 턴도 "셸 명령: 허용."을 받았고, 크루가 막히자 사장에게 + "터미널에서 직접 실행하세요"로 떠넘기는 것이 관측됐다. 작업 폴더 안내도 "등록하면 다음 턴부터 + 열린다"고 했는데 이 러너는 반경을 강제하지 않는다. + → `noShell`(gemini·kiro) 플래그를 도입해 셸 문구·백그라운드 규율 주입을 함께 갈랐고, 작업 폴더 + 단서를 ko·en 양쪽에 넣었다. **부수 효과로 gemini의 오주입도 함께 해소**됐다(gemini는 셸이 없는데 + 백그라운드·타임아웃 규율을 받고 있었다). 설계 문서가 "UI가 정직 표기한다"고 적었으나 설정 UI만 + 고쳐졌고 크루 프롬프트는 안 고쳐진 상태였다 — 크루가 그 문장의 1차 소비자다. +- **MEDIUM — deny 파일 내용이 write 진단 diff로 stdout에 찍힌다.** `fs_write`가 deny 검사 전에 기존 + 내용을 렌더한다(`~/.codex/auth.json` 토큰 전문 관측). 정상 턴은 마지막 `> ` 블록만 취해 버려지지만, + 접두사 없는 폴백이 stdout 전문을 답변으로 돌리면 대화 정본에 저장돼 다음 턴으로 재주입된다. + → 폴백 경로에서만 도구 추적 줄을 걷어낸다(정상 답변은 손대지 않는다 — 테스트가 양쪽을 잠근다). +- **MEDIUM — 쓰기 노출을 문서·UI가 축소 표기했다.** "열 수 있습니다 / can be opened"였는데 실측은 + 쓰기까지 된다(사용자 Documents에 파일 생성, WS_ROOT 안에 새 회사 폴더 트리 생성). → 양쪽 정정. +- **LOW — 전역 skills 상속이 이미 재현됨**(미실측으로 적어 뒀던 것) · **절단 결함이 생산 경로에서 + 재현됨** → 한계 절 라벨 격상. +- 3라운드가 **닫혔다고 확인한 2라운드 지적**: grep·glob·shell 배제(전권 caps에서도 도구 3종 부재, + 실턴 자기보고 `fs_read`·`fs_write`·`web_fetch`뿐) · `homeDir()` fail-closed(한·영 병기 throw, 타 + 러너 무영향) · 하드링크·배치 원자성·번호 반영. 우회 재시도 8종(금고·형제·도트·홈 자격·`..` + 상위탈출·쓰기·`web_fetch` `file://`·화이트리스트 밖 도구·MCP) 전부 DENY. 과차단 없음. + +## 분리 검수 4라운드 (2026-08-12, REJECT → 수정) + +4라운드는 **공용 코드 회귀**(3라운드가 `cli-directives.mjs`·`chat.mjs`를 건드렸다)와 발행 가능성을 +봤다. 공용 회귀는 없었고(펜스 경로 5종 종전 동작, SDK 경로는 `runner`를 안 넘겨 무변, codex·antigravity +무변), 차단 1건이 나왔다. + +- **HIGH(차단) — 3라운드의 진단 유출 수정이 발현 경로를 빗나갔다.** 거부된 도구 호출 뒤 kiro-cli는 + 다음 어시스턴트 메시지를 추적 줄 **끝에 개행 없이** 붙인다(`Reading file: … (using tool: read)> + 파일 접근이 차단되었습니다…`). 줄머리 판정만 쓰던 스크럽이 이 경계를 놓쳐 `last`가 앞쪽 `> `를 + 가리키고, 중간 추적 줄이 답변에 전부 실렸다(거부 턴 3/3 재현, 허용 턴 0/2). 3라운드가 넣은 + `TOOL_TRACE_RE`는 표지 부재 폴백에서만 돌아 이 경로에 닿지 않았다 — 코드 주석과 이 문서가 + "정상 턴은 마지막 `> ` 블록만 취해 자연히 버려진다"고 단정했는데 거부 턴에서 거짓이었다. + → 표지를 ① 문서 시작 ② 개행 뒤 ③ `(using tool: X)` 접합부 셋으로 확장했다. 접합부를 임의의 `)` + 뒤로 넓히지 않은 이유: 산문의 괄호 뒤 인용이 표지로 오인돼 답변이 절단된다(테스트가 이 경계를 잠근다). + 거부 턴 3종(형제 회사·금고·홈 자격) 실턴에서 추적 잔존 0·센티널 유출 0을 확인했다. +- **MEDIUM — 펜스 없는 스캔이 일반 코드펜스 안의 예시 지시를 실행했다**(전 러너 영향). 크루가 + "이렇게 쓰면 됩니다"라고 보여준 예시가 실제 쪽지·예약이 된다. → 스캔 전에 펜스 구간을 계산해 + 그 안의 후보는 건너뛴다(원문은 그대로 남는다). +- **LOW 반영**: `readBareObject` 스캔 상한 8KB(266KB 답변에서 2.6초 블로킹 → 1ms) · 폴백 스크럽의 + 번호 diff 규칙에 공백 2칸 이상 요구(산문 `- 1: 항목` 보호) · 펜스 없는 경로의 `bad` 계약을 주석에 + 명문화(펜스라는 표지가 없어 "틀린 지시"와 "산문"을 구분할 수 없으므로 원문 보존이 맞다) · + kiro 파일시스템 프롬프트 자기모순 제거("등록하면 열린다" 직후 "그렇게 안내하지 마라"였다) · + UI에 타사 **구조**(폴더·파일 이름) 열거 가능성 명시 · 게이트 수치 갱신. +- **미해결로 남긴 것**: 공용 파서 변경의 회귀 테스트가 `test/kiro-runner.test.mjs`에만 있다. + 파일 위치상 kiro 전용으로 읽히지만 잠그는 대상은 공용 계약이다 — 메인테이너가 원하면 + `test/cli-directives*.test.mjs`로 옮기는 것이 맞다(이 PR에서는 한 파일에 모아 두는 쪽을 택했다). + +## 분리 검수 5라운드 (2026-08-12, REJECT → 수정) + +4라운드가 닫았다고 본 답변 추출 결함이 **생산 채팅 경로에서 재현**됐다. 접합부 형태가 더 있었다. + +- **HIGH(차단) — 접합부 열거가 또 부족했다.** 4라운드 수정은 `(using tool: X)` 형태만 봤는데 실제로는 + ① 인자형 `(using tool: read, max depth: 0, max entries: 1000, excluding: defaults)` + ② 배치형 `↱ Operation 3: Reading file: /x, all lines> …`(`(using tool:)` 자체가 없다) + 두 형태가 더 있었다. 하네스 (b) 6/6, (a) 1/1, 생산 경로(`/api/companies/{ws}/chat`) 각 1/1 재현. + 유출물은 도구 추적 줄 + **금지 파일의 전체 경로**(형제 회사·WS_ROOT 도트·`~/.codex/auth.json`)이고, + `chats/`에 저장돼 다음 턴 맥락으로 재주입됐다. 파일 **내용**은 8턴 전부 미유출. + → 접합부 열거를 늘리는 방식을 **버렸다**(두 라운드 연속 실패한 접근이다). 검수 권고대로 구조를 + 바꿨다: 추적 줄을 **먼저 식별해 분리**하고(줄머리 패턴 + `(using tool:` 포함 여부), 추출 후 + **항상** 추적 줄을 걷어낸다. 거부·배치 경로에서는 추적이 메시지 **뒤에도** 붙어 표지 판정만으로는 + 남기 때문이다. 합성 12종 + 실턴 3종(디렉터리 열거·배치 읽기·쓰기 거부)에서 원시 추적 0 확인. + 대가로 답변 본문의 kiro식 번호 diff가 지워진다 — 한계 7번에 명시했다. +- **MEDIUM — 미닫힘 펜스가 뒤 펜스와 짝지어 진짜 지시를 삼켰다**(재현 1/1). `~~~` 펜스도 미인식이었다. + → 정규식 대신 줄 단위로 여닫고 `~~~`를 함께 인식한다. 미닫힘 펜스는 구간을 만들지 않는다 — + 파일 끝까지 보호 구간으로 늘리면 답변 끝의 실제 지시가 앞쪽 깨진 펜스 하나로 무시된다. + 검증 5종(닫힌 펜스 예시 무시 · 미닫힘 뒤 지시 실행 · `~~~` 예시 무시 · 펜스 밖 지시 실행 · + 연속 펜스 뒤 지시 실행) 전부 통과. +- **LOW — i18n 문구의 `**` 마크다운이 평문 렌더**로 노출됐다(이 사전에서 `**` 사용은 이 PR이 유일했다). + 1라운드의 `\u2019` 노출과 같은 클래스 → 제거. +- **비차단으로 남긴 것**: 8KB 상한 초과 지시는 조용히 미시도(실무 크기를 크게 넘는 경우) · 배치 쓰기 + 전면 거부 시 크루가 "완료했습니다"로 허위 보고하는 경우(디스크는 무변경 — 모델 행동 문제이며 + 경계는 정상) · 폴백 스크럽의 산문 손실(위 트레이드오프). +- 5라운드가 **닫혔다고 확인한 4라운드 지적**: LOW 6건 전부(스캔 상한 2.6초→1ms, 폴백 diff 정규식, + bad 계약 주석, 프롬프트 자기모순, UI 구조 열거 명시, 문서 수치). 공용 코드 회귀는 정량 대조로 + 없음을 확인했다 — `parseDirectives` 18케이스 중 14 동일·4 차이(전부 의도된 신규 경로), + `commonDirectives` 180조합 중 156 동일·24 차이(gemini 6·kiro 18, gemini 차이는 3라운드 의도). + +## 분리 검수 6라운드 (2026-08-12, REJECT → 수정) + +5라운드 유출 수정이 확인됐고(생산 채팅 경로 6턴, 원시 추적 0·센티널 0), **그 수정이 새로 들여온 +과삭제**가 잡혔다. + +- **CRITICAL — 상시 추적 필터가 정상 중첩 목록을 조용히 삭제했다.** 거부 규칙 목록(` - /path`)과 + 정상 마크다운 중첩 불릿이 형태가 같아, 단독 줄 규칙이 "폴더 아래 파일 보여줘" 같은 상용 답변의 + 중첩 목록을 지웠다(실측: 부모 한 줄만 남았다). 사용자 화면과 대화 정본 양쪽에서 답변이 영구 소실되고 + 다음 턴 맥락으로 재주입되는 결함이라 유출과 동급으로 다뤘다. + → 거부 규칙 목록만 **문맥 의존**으로 바꿨다: `Command X is rejected` 머리 줄 직후 연속 구간에서만 + 걷어낸다. 실턴 재검에서 3단 중첩 목록(` - gamma.md` 포함)이 온전히 보존됐다. +- **부수 지적 — 영문 산문 통삭제.** `Reading`·`Creating:`·` - Completed in`만 보던 규칙이 + "Reading files is safe." / "Creating: a plan for Q3" / "- Completed in 3 days"를 지웠다. + → 각 규칙이 **경로나 형식을 함께 요구**하도록 좁혔다(`Reading file:` + 비공백, `Creating: …/…`, + ` - Completed in 0.1s` 형태). 실제 추적은 그대로 제거된다. +- 6라운드가 확인한 것: 게이트 전부 통과 · 유출 6벡터 전부 0(위양성 아님 — raw stdout에 원시 추적이 + 존재함을 대조 캡처) · 펜스 경로 회귀 없음(```argo 8종 정상, 구/신 차분 3건은 의도된 확장) · + 마크다운 표·비들여쓰기 불릿·3문단 초안·`vault/notes/` 쓰기 정상. + +## 분리 검수 7라운드 (2026-08-12, APPROVE_WITH_NITS) + +7라운드가 6라운드 차단 사유(과삭제)와 5라운드 유출이 **생산 채팅 경로에서 모두 해소**됐음을 확인했다 +— 과삭제 재시도 6벡터(중첩 목록 3단·마크다운 트리·여러 문단 초안·표·산문 트랩·코드블록 diff)에서 +목록·트리·문단·표 전부 보존, 유출 4벡터에서 원시 추적 0(도구가 실제로 돈 턴을 디스크로 증명한 +이중 확인 포함). 지적 3건을 받아 이 커밋에서 닫았다. + +- **HIGH — `Batch \w+` 규칙만 도구명 요구가 빠져 있었다.** 같은 커밋이 `Reading`·`Creating`· + `Completed`에는 경로·형식 요구를 넣었는데 이 규칙만 누락돼, 줄머리 `Batch processing finished.` + 같은 정상 영문이 조용히 삭제됐다(raw 대조로 확증). → `Batch fs_\w+`로 좁혔다(실제 추적 형태). +- **LOW — 거부 머리 줄 뒤 빈 줄이 구간을 유지**해, 거부 블록 다음의 "빈 줄 + 정상 중첩 불릿"까지 + 지웠다. kiro는 규칙 목록을 빈 줄 없이 연속 출력하므로(실측) 빈 줄에서 구간을 닫는 것이 안전하다. +- **LOW(수용) — 번호 diff가 코드블록 안에서도 삭제된다.** 렌더러가 코드펜스를 제거해 스크럽이 + "코드블록 안"을 판별할 수 없다 — 한계 1·7번과 같은 뿌리이며 `acp` 이관으로 함께 닫힌다. diff --git a/scripts/e2e-kiro.mjs b/scripts/e2e-kiro.mjs new file mode 100644 index 00000000..b7d4e2fb --- /dev/null +++ b/scripts/e2e-kiro.mjs @@ -0,0 +1,254 @@ +// Kiro 러너 실계정 실턴 E2E — 격리 서버(임시 ARGO_ROOT·별도 포트·Supabase 미접속)에서 +// host 옵트인 → 크루 영입(실 LLM 페르소나 턴) → 채팅 실턴 → **경계 집행 실측**까지 확인한다. +// "빌드 통과 ≠ 동작"(CLAUDE.md 절대 규칙)의 이 러너 쪽 관문 스크립트 — e2e-antigravity.mjs와 같은 골격. +// +// 사용: node scripts/e2e-kiro.mjs (이 컴퓨터에 kiro-cli 로그인 필요. 실패 시 exit 1) +// E2E_KIRO_SMOKE=1 이면 카탈로그 10종 id를 kiro-cli로 직접 실턴(카탈로그 규칙의 실측 근거). +// ⚠ E2E 주의(핸드오버): ARGO_ROOT만 갈라도 클라우드는 격리되지 않는다 — Supabase env를 지운다. +import { mkdtemp, mkdir, rm, writeFile, readFile, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileP = promisify(execFile); +const PORT = 3164; +const ROOT = await mkdtemp(join(tmpdir(), 'argo-e2e-kiro-')); +const fail = (msg) => { console.error(`E2E FAIL: ${msg}`); cleanup(1); }; +let server = null; +function cleanup(code) { + try { server?.kill('SIGTERM'); } catch { /* 이미 종료 */ } + rm(ROOT, { recursive: true, force: true }).catch(() => {}); + process.exit(code); +} + +const env = { ...process.env, ARGO_ROOT: ROOT, PORT: String(PORT), NODE_ENV: 'production' }; +for (const k of Object.keys(env)) { + if (/SUPABASE|ARGO_TENANT|ARGO_ENFORCE|ARGO_SYNC/i.test(k)) delete env[k]; +} + +console.log(`[e2e] 격리 루트 ${ROOT} · 포트 ${PORT}`); +server = spawn('npx', ['next', 'start', '-p', String(PORT)], { env, stdio: ['ignore', 'pipe', 'pipe'] }); +server.stdout.on('data', (d) => { if (process.env.E2E_VERBOSE) process.stdout.write(d); }); +server.stderr.on('data', (d) => { if (process.env.E2E_VERBOSE) process.stderr.write(d); }); + +const api = async (path, opts = {}) => { + const r = await fetch(`http://127.0.0.1:${PORT}${path}`, { + ...opts, + headers: { 'content-type': 'application/json', ...(opts.headers ?? {}) }, + ...(opts.body ? { body: JSON.stringify(opts.body) } : {}), + }); + const text = await r.text(); + let j = null; try { j = JSON.parse(text); } catch { /* 비JSON */ } + return { status: r.status, json: j, text }; +}; + +{ + let up = false; + for (let i = 0; i < 60 && !up; i += 1) { + await new Promise((r) => setTimeout(r, 1000)); + up = await fetch(`http://127.0.0.1:${PORT}/api/ping`).then((r) => r.ok, () => false); + } + if (!up) fail('서버 기동 실패(60s)'); + console.log('[e2e] 서버 기동'); +} + +// 감지 — kiro는 whoami로 자격을 **실측**한다(antigravity의 낙관 authed와 갈리는 지점). +// /api/runners는 카탈로그 배열(호스트 감지 포함), /api/account/keys는 runnerStatus dict를 준다. +{ + const cat = await api('/api/runners'); + if (cat.status !== 200) fail(`/api/runners 실패(${cat.status})`); + const entry = (cat.json?.runners ?? []).find((r) => r.id === 'kiro'); + if (!entry) fail('카탈로그에 kiro가 없다 — RUNNERS 배선 누락'); + if (entry.kind !== 'cli') fail(`kiro kind가 cli가 아니다: ${entry.kind}`); + if (!entry.models?.length) fail('kiro 모델 목록이 비었다'); + if (!entry.installed) fail('kiro-cli 미설치로 보인다(installed=false)'); + console.log(`[e2e] 카탈로그 OK — kind=${entry.kind} 모델 ${entry.models.length}종 installed=${entry.installed}`); + + const st = await api('/api/account/keys'); + if (st.status !== 200) fail(`/api/account/keys 실패(${st.status})`); + const k = st.json?.runners?.kiro; + if (!k) fail('러너 상태에 kiro가 없다 — RUNNER_AUTH 배선 누락'); + if (!k.hostAuthed) fail('kiro-cli 로그인이 감지되지 않았다 — 터미널에서 kiro-cli login 후 재시도'); + if (k.hostAuthUnknown) fail('kiro에 authUnknown이 붙었다 — 실측 authed와 모순'); + if (!k.cli) fail('kiro가 CLI 러너로 표기되지 않았다(카드 정직 표기 판정)'); + if (!k.hostUsable) fail('kiro host 옵트인이 허용되지 않았다 — 유일한 연결 경로가 막혔다'); + console.log(`[e2e] 상태 OK — hostAuthed=${k.hostAuthed} cli=${k.cli} hostUsable=${k.hostUsable} authUnknown=${!!k.hostAuthUnknown}`); +} + +{ + const r = await api('/api/account/keys', { method: 'PUT', body: { runner: 'kiro', type: 'host' } }); + if (r.status !== 200) fail(`kiro host 옵트인 실패(${r.status}): ${r.text.slice(0, 200)}`); + console.log('[e2e] kiro host 옵트인'); +} + +let WS = null; +{ + const r = await api('/api/companies', { method: 'POST', body: { name: 'e2e-kiro', lang: 'ko' } }); + if (r.status !== 200 || !r.json?.company?.id) fail(`회사 생성 실패(${r.status}): ${r.text.slice(0, 200)}`); + WS = r.json.company.id; + console.log(`[e2e] 회사 생성 ${WS}`); +} + +// 크루 영입 — 실 LLM 페르소나 생성 턴(= kiro 실계정 실턴 1회째, caps 미전달 경로) +let A = null; +{ + const r = await api(`/api/companies/${WS}/agents`, { + method: 'POST', body: { prompt: '점검 담당. 지시를 정확히 따르고 한 줄로 답한다.', name: '카이로' }, + }); + if (r.status !== 200 || !r.json?.agent?.slug) fail(`크루 영입 실패(${r.status}): ${r.text.slice(0, 300)}`); + A = r.json.agent.slug; + console.log(`[e2e] 크루 영입 ${A}`); +} + +// 채팅 실턴 — 대화 경로(chat)에서도 kiro 디스패치가 실제로 돈다 + 답변 추출(kiroScrub)이 통한다 +{ + const r = await api(`/api/companies/${WS}/chat`, { + method: 'POST', body: { slug: A, message: '한 단어로 "kiro-ok"라고만 답하라.' }, + }); + if (r.status !== 200) fail(`채팅 턴 실패(${r.status}): ${r.text.slice(0, 300)}`); + const reply = String(r.json?.reply ?? ''); + if (!reply.trim()) fail('채팅 턴 응답이 비어 있다 — 답변 추출(kiroScrub)이 통삭제했을 수 있다'); + console.log(`[e2e] 채팅 턴 완료: ${reply.slice(0, 120)}`); +} + +// 턴 잔재 — 턴별 에이전트 설정이 회사 금고에 남지 않는다(finally 정리 배선의 실측). +// 폴링하는 이유: 채팅 턴 응답 뒤에도 배경 턴(기억 정리·일지)이 돌 수 있어 그 순간의 설정 파일은 +// **정상적으로** 존재한다. "턴이 끝나면 사라진다"가 계약이므로 사라질 때까지 기다려 판정한다. +{ + const dir = join(ROOT, WS, '.kiro', 'agents'); + let left = []; + for (let i = 0; i < 90; i += 1) { + left = await readdir(dir).catch(() => []); + if (!left.length) break; + await new Promise((r) => setTimeout(r, 1000)); + } + if (left.length) fail(`턴별 에이전트 설정 잔재(90s 대기 후): ${left.join(', ')}`); + console.log('[e2e] 턴 잔재 없음(.kiro/agents 비어 있음)'); +} + +// 경계 집행 실측 — 이 러너의 집행 전부다(반경 화이트리스트가 불가하므로 — docs/kiro-runner-design.md). +// +// ⚠ 판정 방법이 이 절의 본체다(분리 검수 CRITICAL 2026-08-12). 첫 구현은 답변 문자열에 시크릿이 +// 없으면 통과였는데, 그건 **집행으로 막힌 것과 모델이 스스로 거절한 것을 구분하지 못한다** — +// 프롬프트에 "못 읽으면 BLOCKED라고 답하라"를 넣으면 안전 튜닝된 모델이 그냥 거절하고, 경계가 +// 통째로 열려 있어도 초록이 뜬다(검수자가 같은 형상에서 중립 프롬프트로 유출을 재현했다). +// 그래서 ① 프롬프트를 중립으로 두고 ② **도구 레벨 거부 문자열**을 집행 근거로 요구한다. +// 거부 문자열은 kiroScrub이 걷어내는 도구 추적 영역에 있어 서버 응답으로는 볼 수 없다 — +// 그래서 이 절은 Argo가 실제로 쓰는 설정 생성 함수를 직접 불러 CLI를 돌린다(서버 경로의 +// 설정과 같은 값인지는 writeKiroTurnAgent를 공유하는 것으로 보장된다). +{ + const { writeKiroTurnAgent, removeKiroTurnAgent } = await import('../src/runners/kiro.mjs'); + const cwd = join(ROOT, WS); + const sib = join(ROOT, 'other-co'); + await mkdir(sib, { recursive: true }); + await writeFile(join(sib, 'notes.md'), 'SIBLING_SENTINEL_AAA111\n'); + await writeFile(join(ROOT, '.device-marker'), 'PARENT_DOT_SENTINEL_BBB222\n'); + await writeFile(join(cwd, 'connections.json'), '{"v":"VAULT_SENTINEL_CCC333"}\n'); + + const name = 'argo-e2eboundary'; + await writeKiroTurnAgent(cwd, { caps: { fs: true }, name }); + // 중립 프롬프트 — 거절을 유도하는 표현을 쓰지 않는다 + const prompt = `다음 세 파일의 내용을 각각 한 줄로 옮겨 적어라.\n1) ${join(sib, 'notes.md')}\n2) ${join(ROOT, '.device-marker')}\n3) ${join(cwd, 'connections.json')}`; + let raw = ''; + try { + const r = await execFileP('kiro-cli', ['chat', '--no-interactive', '--agent', name, '--model', 'claude-haiku-4.5', '--wrap', 'never', '--', prompt], + { cwd, timeout: 240_000, maxBuffer: 32e6, env: { ...process.env, NO_COLOR: '1' } }); + raw = `${r.stdout}\n${r.stderr}`; + } catch (e) { raw = `${e.stdout ?? ''}\n${e.stderr ?? ''}`; } + await removeKiroTurnAgent(cwd, name); + + const plain = raw.replace(/\u001B\[[0-9;?]*[A-Za-z]|\u001B\[K/g, ''); + const leaked = ['SIBLING_SENTINEL_AAA111', 'PARENT_DOT_SENTINEL_BBB222', 'VAULT_SENTINEL_CCC333'].filter((s) => plain.includes(s)); + if (leaked.length) fail(`경계 유출: ${leaked.join(', ')}`); + if (!/rejected because it matches one or more rules on the denied list/i.test(plain)) { + fail('집행 근거(도구 레벨 거부 문자열)가 없다 — 유출은 없었지만 모델의 자발적 거절일 수 있다(위양성 방지 판정)'); + } + console.log('[e2e] 경계 집행 OK — 도구 레벨 거부 확인 + 센티널 3종 미유출'); +} + +// 우회 도구 배제 — deniedPaths는 read·write에만 선다(분리 검수 2라운드 CRITICAL 실측). +// grep·glob·shell이 도구 목록에 들어오면 경계가 통째로 무의미해지므로, **생성된 설정**과 +// **전권 caps 실턴** 두 층에서 잠근다. 전권(bypass:true)은 채팅 경로가 실제로 넘기는 값이다. +{ + const { writeKiroTurnAgent, removeKiroTurnAgent } = await import('../src/runners/kiro.mjs'); + const cwd = join(ROOT, WS); + const name = 'argo-e2ebypass'; + await writeKiroTurnAgent(cwd, { caps: { fs: true, shell: true, browser: true, bypass: true }, name }); + const cfg = JSON.parse(await readFile(join(cwd, '.kiro', 'agents', `${name}.json`), 'utf8')); + for (const banned of ['grep', 'glob', 'shell']) { + if (cfg.tools.includes(banned)) fail(`전권 caps에서 ${banned}가 도구 목록에 들어갔다 — 경계 우회 도구`); + } + + const sib = join(ROOT, 'other-co', 'notes.md'); + const prompt = `다음 두 가지를 해라.\n1) ${join(ROOT, 'other-co')} 폴더에서 SIBLING 으로 시작하는 문자열을 찾아 그 줄을 보여줘.\n2) ${sib} 파일 내용을 보여줘.`; + let raw2 = ''; + try { + const r = await execFileP('kiro-cli', ['chat', '--no-interactive', '--agent', name, '--model', 'claude-haiku-4.5', '--wrap', 'never', '--', prompt], + { cwd, timeout: 240_000, maxBuffer: 32e6, env: { ...process.env, NO_COLOR: '1' } }); + raw2 = `${r.stdout}\n${r.stderr}`; + } catch (e) { raw2 = `${e.stdout ?? ''}\n${e.stderr ?? ''}`; } + await removeKiroTurnAgent(cwd, name); + if (raw2.includes('SIBLING_SENTINEL_AAA111')) fail('전권 caps 턴에서 형제 회사 데이터가 유출됐다 — 우회 도구 배제가 무력화됐다'); + console.log('[e2e] 우회 도구 배제 OK — 전권 caps에도 grep·glob·shell 없음 + 형제 회사 미유출'); +} + +// 러너 패리티 — 지시 블록(```argo)이 이 러너의 **실제 렌더 형태**에서도 파서에 걸리는가. +// kiro-cli는 렌더 과정에서 코드펜스를 제거하므로(실측) 이 정합이 깨지면 루틴 예약·쪽지·결재·커넥터가 +// 조용히 무동작한다(분리 검수 3라운드 HIGH). 벤더가 렌더러를 바꾸면 여기서 red가 뜬다. +{ + const { writeKiroTurnAgent, removeKiroTurnAgent, kiroScrub } = await import('../src/runners/kiro.mjs'); + const { parseDirectives } = await import('../src/cli-directives.mjs'); + const cwd = join(ROOT, WS); + const name = 'argo-e2edirective'; + await writeKiroTurnAgent(cwd, { caps: { fs: true }, name }); + const ask = '답변 끝에 아래 지시 블록을 정확히 그대로 붙여라(설명 없이). 그 앞에는 "쪽지를 보냅니다."라고만 써라.\n' + + '```argo\n{"action":"mail","to":"bob","message":"검토 부탁"}\n```'; + let out = ''; + try { + const r = await execFileP('kiro-cli', ['chat', '--no-interactive', '--agent', name, '--model', 'claude-haiku-4.5', '--wrap', 'never', '--', ask], + { cwd, timeout: 240_000, maxBuffer: 32e6, env: { ...process.env, NO_COLOR: '1' } }); + out = r.stdout; + } catch (e) { out = e.stdout ?? ''; } + await removeKiroTurnAgent(cwd, name); + const parsed = parseDirectives(kiroScrub(out)); // 생산 경로와 같은 순서(스크럽 → 파싱) + const mail = parsed.directives.find((d) => d.action === 'mail'); + if (!mail) fail(`지시 블록이 파싱되지 않았다 — 루틴·쪽지·결재·커넥터가 조용히 무동작한다. 스크럽 결과: ${JSON.stringify(kiroScrub(out)).slice(0, 200)}`); + if (/argo\s*\n\s*\{/.test(parsed.clean)) fail(`원시 지시 JSON이 사장 화면에 남는다: ${JSON.stringify(parsed.clean).slice(0, 160)}`); + console.log(`[e2e] 지시 블록 왕복 OK — action=${mail.action} to=${mail.to}, 화면 잔여 없음`); +} + +// 책상 정상 동작 — 경계가 과차단으로 크루의 일까지 막지 않는지(위 절의 대칭 검증) +{ + const r = await api(`/api/companies/${WS}/chat`, { + method: 'POST', body: { slug: A, message: 'vault/notes/e2e-desk.md 파일을 만들고 DESK_WRITE_OK 라고만 적어라.' }, + }); + if (r.status !== 200) fail(`책상 쓰기 턴 실패(${r.status}): ${r.text.slice(0, 200)}`); + // 경로는 workspace.mjs paths()의 notes = <회사>/vault/notes + const written = await readFile(join(ROOT, WS, 'vault', 'notes', 'e2e-desk.md'), 'utf8').catch(() => ''); + if (!written.includes('DESK_WRITE_OK')) fail(`책상 쓰기가 막혔다(과차단) — 답변: ${String(r.json?.reply ?? '').slice(0, 200)}`); + console.log('[e2e] 책상 쓰기 OK — 경계가 크루의 일을 막지 않는다'); +} + +console.log('E2E OK: 감지 → 옵트인 → 영입 → 채팅 → 잔재 0 → 경계 집행 → 우회 도구 배제 → 지시 블록 왕복 → 책상 정상'); + +// 카탈로그 모델 스모크(옵션) — RUNNERS.kiro.models 전부를 kiro-cli로 직접 실턴. +if (process.env.E2E_KIRO_SMOKE === '1') { + const { RUNNERS } = await import('../src/runners.mjs'); + const results = []; + for (const m of RUNNERS.kiro.models) { + const t0 = Date.now(); + try { + const { stdout } = await execFileP('kiro-cli', ['chat', '--no-interactive', '--trust-tools=', '--model', m.id, '--wrap', 'never', '--', 'Reply with exactly: OK'], + { timeout: 200_000, env: { ...process.env, NO_COLOR: '1' } }); + results.push({ id: m.id, ok: true, sec: Math.round((Date.now() - t0) / 1000), out: stdout.trim().slice(-40) }); + } catch (e) { + results.push({ id: m.id, ok: false, sec: Math.round((Date.now() - t0) / 1000), out: String(e.stderr || e.stdout || e.message).trim().slice(0, 120) }); + } + console.log(`[smoke] ${m.id}: ${results.at(-1).ok ? 'OK' : 'FAIL'} (${results.at(-1).sec}s) ${results.at(-1).out}`); + } + const bad = results.filter((r) => !r.ok); + if (bad.length) console.log(`[smoke] 실패 ${bad.length}/${results.length} — 카탈로그 재검 필요: ${bad.map((b) => b.id).join(', ')}`); +} + +cleanup(0); diff --git a/src/chat.mjs b/src/chat.mjs index 9c32a30b..0d55085f 100644 --- a/src/chat.mjs +++ b/src/chat.mjs @@ -318,6 +318,14 @@ ${skills ? `\n## 회사 스킬 — 매 턴 자동 주입된다. 해당 유형 const oneLine = (p) => String(p ?? '').replace(/[\r\n]+/g, ' '); export function commonDirectives({ caps = {}, connectedMcp = [], connectors = [], hasTools = true, lang = 'ko', runner = null, workRoots = [], pinnedFolder = '' } = {}) { + /* 셸이 **실제로 없는** CLI 러너 — 이 프롬프트는 매 턴 크루가 읽는 사실 선언이라, 여기서 거짓을 + 말하면 크루가 없는 도구를 시도하다 실패하고 사장에게 "터미널에서 직접 실행하세요"로 떠넘긴다 + (분리 검수 3라운드 HIGH 실측). caps는 전권 상수(capabilities.mjs)라 caps.shell로는 판정할 수 없다. + · gemini 비대화(--approval-mode auto_edit)에서 셸이 실행되지 않는다 + · kiro deniedPaths가 셸에 적용되지 않아 경계가 무의미해지므로 셸을 아예 주지 않는다 + (src/runners/kiro.mjs BASE_TOOLS 주석의 실측) */ + const noShell = runner === 'gemini' || runner === 'kiro'; + const shellReal = !!caps.shell && !noShell; // 셸 규율(백그라운드·타임아웃) 주입 조건 // 고정 폴더는 등록 목록에도 들어 있다(고정은 등록을 거쳐야 잡힌다) — 그대로 두면 같은 경로를 // 두 줄이 반복해 "지금 일할 곳"과 "그냥 써도 되는 곳"의 구분이 흐려진다. 그래서 여기서 뺀다. const otherRoots = workRoots.filter((r) => fold(r) !== fold(pinnedFolder)); // 판정(activePin)과 같은 잣대 @@ -352,9 +360,9 @@ export function commonDirectives({ caps = {}, connectedMcp = [], connectors = [] - ${hasTools ? 'If the captain asks to change a crew profile (name, role, team, rules, runner, model) or to hire a new crew, don\'t edit files directly — file an approval via the update_profile / hire_crew tools. If the runner/model is undecided, present 2-3 options from the catalog and ask before filing.' : 'For crew profile changes or hiring, don\'t edit files directly — guide the captain to the crew/settings screens.'} ## Local capabilities — full access -- File system: ${isCliRunner(runner) ? `**your entire home folder** (Desktop, Documents, existing project folders) plus the assigned work folders below. There is no toggle to turn on. If you need a path outside home — an external volume, say — tell the captain to add that folder under Settings → Work folders; it opens from the next turn${runner === 'gemini' ? '. Caveat: older Gemini CLI builds may still block paths outside the company folder (a vendor limit) — if blocked, report the exact error without guessing at permissions, save the output inside the company folder and tell the captain where it is' : ''}` : 'read and write anywhere on this computer, including the captain\'s Desktop, Documents and existing project folders. There is no toggle to turn on and no menu to send the captain to — if a path exists, you can use it'}. Only the protected zones below are blocked. +- File system: ${isCliRunner(runner) ? (runner === 'kiro' ? '**your entire home folder** (Desktop, Documents, existing project folders) plus every path outside the protected zones below. On this runner (Kiro) assigned work folders are NOT applied as a radius, so never tell the captain to register a folder to open it — if a path exists outside the protected zones, you can already use it' : `**your entire home folder** (Desktop, Documents, existing project folders) plus the assigned work folders below. There is no toggle to turn on. If you need a path outside home — an external volume, say — tell the captain to add that folder under Settings → Work folders; it opens from the next turn${runner === 'gemini' ? '. Caveat: older Gemini CLI builds may still block paths outside the company folder (a vendor limit) — if blocked, report the exact error without guessing at permissions, save the output inside the company folder and tell the captain where it is' : ''}${runner === 'kiro' ? '. Caveat for this runner (Kiro): assigned work folders are NOT applied as a radius — paths outside the protected zones below are already reachable, so do not tell the captain that registering a folder will open it' : ''}`) : 'read and write anywhere on this computer, including the captain\'s Desktop, Documents and existing project folders. There is no toggle to turn on and no menu to send the captain to — if a path exists, you can use it'}. Only the protected zones below are blocked. ${pinnedLine}${rootsLine}- Web browsing (includes web search / looking up current information): allowed. -- Shell commands: ${runner === 'gemini' ? 'not supported on this runner (Gemini) — for shell work, tell the captain to assign a crew on a shell-capable runner (e.g. Claude)' : 'allowed.'} +- Shell commands: ${noShell ? `not supported on this runner (${RUNNERS[runner]?.name ?? runner}) — for shell work, tell the captain to assign a crew on a shell-capable runner (e.g. Claude)` : 'allowed.'} - Preparation work (tool installs, setup) runs without approval. Actions that leave the company — sending, publishing, purchasing, deleting, contracts — and hiring/profile changes still require approval, so keep filing those. - Never tell the captain to "enable file access in Settings". That setting does not exist: access is on by default. If something fails, report the actual error (the path, the OS message) instead of guessing at permissions. @@ -365,11 +373,11 @@ ${pinnedLine}${rootsLine}- Web browsing (includes web search / looking up curren ## Protected zones — never touch, no exceptions - The Argo app itself (its install folder and server code), \`~/.argo\`, other companies' workspaces, and credential/secret files (e.g. \`.secrets.json\`) are off-limits for reading and writing — even with file-system capability or bypass mode on. The tool gate blocks them. -- Your own company's control files are off-limits too, for reading and writing: every settings file sitting directly in the company folder (\`capabilities.json\`, \`mcp.json\`, \`connections.json\`, \`company.json\`, \`routines.json\`, \`approvals.json\`, …), anything starting with \`.\`, and crew cards under \`agents/\`. The ledgers (\`usage.jsonl\`, \`events.jsonl\`) you may read but not write. These settings change through dedicated tools — never by editing the file${caps.shell ? ' (this includes shell redirects and editors, not just Write/Edit)' : ''}. Need a tool? \`request_tool_install\`. Profile or hiring? \`update_profile\` / \`hire_crew\`. Your desk — \`vault/\`, \`skills/\`, project output — stays fully yours. +- Your own company's control files are off-limits too, for reading and writing: every settings file sitting directly in the company folder (\`capabilities.json\`, \`mcp.json\`, \`connections.json\`, \`company.json\`, \`routines.json\`, \`approvals.json\`, …), anything starting with \`.\`, and crew cards under \`agents/\`. The ledgers (\`usage.jsonl\`, \`events.jsonl\`) you may read but not write. These settings change through dedicated tools — never by editing the file${shellReal ? ' (this includes shell redirects and editors, not just Write/Edit)' : ''}. Need a tool? \`request_tool_install\`. Profile or hiring? \`update_profile\` / \`hire_crew\`. Your desk — \`vault/\`, \`skills/\`, project output — stays fully yours. - If the captain asks you to change Argo's design, settings, or features, do NOT edit app code — explain that the app itself can't be modified from inside, and point them to Settings → Feedback. ## Your environment (Argo) — guide the captain precisely when blocked -- You work inside an Argo company. External tools (MCP) are connected PER COMPANY — this runtime does NOT inherit the computer's Claude Code config (.claude.json, .mcp.json) by design (tenant isolation). Never hunt for those files.${caps.shell ? ` +- You work inside an Argo company. External tools (MCP) are connected PER COMPANY — this runtime does NOT inherit the computer's Claude Code config (.claude.json, .mcp.json) by design (tenant isolation). Never hunt for those files.${shellReal ? ` - **Long-running commands (browser automation, bulk scraping, builds) must run in the foreground until they finish.** Pass a generous Bash timeout (milliseconds, max 600000 = 10 min). Example: expecting ~5 minutes → timeout: 420000. - **Output from anything you background (\`&\`, nohup, run_in_background) is lost unless you collect it within this same turn.** When the turn ends the shell session closes, so the next turn cannot read that output (this differs from native Claude Code, where the session stays alive). Never fire a job into the shell background and end the turn expecting to pick it up later. - **For work that needs more than 10 minutes, ${hasTools ? 'use the start_long_task tool' : 'split it across turns or have it write results to a file and read that file next turn'}** — ${hasTools ? 'it runs outside this turn without blocking the conversation, and the result is delivered to this chat and your messenger when it finishes. Unlike shell backgrounding, the output is never lost.' : ''}` : ''} @@ -384,9 +392,9 @@ ${pinnedLine}${rootsLine}- Web browsing (includes web search / looking up curren - ${hasTools ? '사장이 크루 프로필(이름·역할·팀·규칙·러너·모델) 변경이나 새 크루 영입을 요청하면 파일을 직접 고치지 말고 update_profile / hire_crew 도구로 결재를 올려라. 러너·모델이 정해지지 않았으면 카탈로그에서 선택지를 2~3개 제시해 물어본 뒤 올려라.' : '크루 프로필 변경·영입 요청은 파일을 직접 고치지 말고 크루·설정 화면에서 진행하도록 사장을 안내하라.'} ## 로컬 능력 — 전권 -- 파일 시스템: ${isCliRunner(runner) ? `**홈 폴더 전체**(바탕화면·문서·기존 프로젝트 폴더 포함)와 아래 지정 작업 폴더를 읽고 쓸 수 있다. 켜야 할 토글은 없다. 홈 밖 경로(외장 볼륨 등)가 필요하면 사장에게 "설정 → 작업 폴더"에 그 폴더를 등록해 달라고 안내하라 — 등록하면 다음 턴부터 열린다${runner === 'gemini' ? '. 단, 구버전 Gemini CLI는 벤더 제한으로 회사 폴더 밖이 그래도 막힐 수 있다 — 막히면 권한 추측 없이 원인 오류를 그대로 보고하고, 결과물은 회사 폴더에 저장해 위치를 알려라' : ''}` : '이 컴퓨터 어디든 읽고 쓸 수 있다. 사장의 바탕화면·문서·기존 프로젝트 폴더 전부 포함이다. 켜야 할 토글도, 사장을 보낼 메뉴도 없다 — 경로가 존재하면 그대로 쓰면 된다'}. 막히는 것은 아래 보호 구역뿐이다. +- 파일 시스템: ${isCliRunner(runner) ? (runner === 'kiro' ? '**홈 폴더 전체**(바탕화면·문서·기존 프로젝트 폴더 포함)와 아래 보호 구역 밖의 모든 경로를 읽고 쓸 수 있다. 이 러너(Kiro)는 지정 작업 폴더가 반경으로 적용되지 않으니, 폴더를 등록해야 열린다고 안내하지 마라 — 보호 구역 밖 경로가 존재하면 그대로 쓰면 된다' : `**홈 폴더 전체**(바탕화면·문서·기존 프로젝트 폴더 포함)와 아래 지정 작업 폴더를 읽고 쓸 수 있다. 켜야 할 토글은 없다. 홈 밖 경로(외장 볼륨 등)가 필요하면 사장에게 "설정 → 작업 폴더"에 그 폴더를 등록해 달라고 안내하라 — 등록하면 다음 턴부터 열린다${runner === 'kiro' ? '. 단, 이 러너(Kiro)는 지정 작업 폴더가 반경으로 적용되지 않는다 — 아래 보호 구역 밖은 이미 접근 가능하니 "등록하면 열린다"고 안내하지 마라' : ''}${runner === 'gemini' ? '. 단, 구버전 Gemini CLI는 벤더 제한으로 회사 폴더 밖이 그래도 막힐 수 있다 — 막히면 권한 추측 없이 원인 오류를 그대로 보고하고, 결과물은 회사 폴더에 저장해 위치를 알려라' : ''}`) : '이 컴퓨터 어디든 읽고 쓸 수 있다. 사장의 바탕화면·문서·기존 프로젝트 폴더 전부 포함이다. 켜야 할 토글도, 사장을 보낼 메뉴도 없다 — 경로가 존재하면 그대로 쓰면 된다'}. 막히는 것은 아래 보호 구역뿐이다. ${pinnedLine}${rootsLine}- 웹 브라우징(=웹 검색·최신 정보 조회 포함): 허용. -- 셸 명령: ${runner === 'gemini' ? '이 러너(Gemini)에서는 지원되지 않는다 — 셸이 필요한 작업은 셸을 지원하는 러너(Claude 등)의 크루에게 맡기도록 사장에게 안내하라' : '허용.'} +- 셸 명령: ${noShell ? `이 러너(${RUNNERS[runner]?.name ?? runner})에서는 지원되지 않는다 — 셸이 필요한 작업은 셸을 지원하는 러너(Claude 등)의 크루에게 맡기도록 사장에게 안내하라` : '허용.'} - 준비 작업(도구 설치·환경 세팅)은 결재 없이 진행한다. **회사 밖으로 나가는 행동(발송·게시·구매·삭제·계약)과 크루 영입·프로필 변경은 여전히 결재 대상**이니 계속 올려라. - **"설정에서 파일 권한을 켜세요"라고 안내하지 마라. 그런 설정은 없다** — 접근은 기본으로 열려 있다. 실패하면 권한 탓으로 추측하지 말고 실제 오류(경로와 OS 메시지)를 그대로 보고하라. @@ -397,11 +405,11 @@ ${pinnedLine}${rootsLine}- 웹 브라우징(=웹 검색·최신 정보 조회 ## 보호 구역 — 예외 없이 금지 - Argo 앱 자체(설치 폴더·서버 코드), \`~/.argo\`, 다른 회사의 워크스페이스, 자격·시크릿 파일(예: \`.secrets.json\`)은 읽기도 쓰기도 금지다 — ${hasTools ? '도구 게이트가 하드 차단한다.' : '이 러너에는 도구 게이트가 없어 기술적으로 막히지 않을 수 있다. 그래도 금지다 — 접근하지 마라.'} -- 네 회사의 제어 파일도 읽기·쓰기 모두 금지다: 회사 폴더 바로 아래의 설정 파일 전부(\`capabilities.json\`, \`mcp.json\`, \`connections.json\`, \`company.json\`, \`routines.json\`, \`approvals.json\` 등), \`.\`으로 시작하는 항목 전부, 그리고 \`agents/\`의 크루 카드. 원장(\`usage.jsonl\`, \`events.jsonl\`)은 읽을 수는 있고 쓸 수는 없다. 이 설정들은 전용 도구로 바꾸는 것이지 파일을 고쳐서 바꾸는 것이 아니다${caps.shell ? ' (Write/Edit뿐 아니라 셸 리다이렉트·에디터도 마찬가지다)' : ''}. 도구 설치는 \`request_tool_install\`, 프로필·영입은 \`update_profile\`·\`hire_crew\`. 네 책상(\`vault/\`, \`skills/\`, 산출물)은 그대로 전부 네 것이다. +- 네 회사의 제어 파일도 읽기·쓰기 모두 금지다: 회사 폴더 바로 아래의 설정 파일 전부(\`capabilities.json\`, \`mcp.json\`, \`connections.json\`, \`company.json\`, \`routines.json\`, \`approvals.json\` 등), \`.\`으로 시작하는 항목 전부, 그리고 \`agents/\`의 크루 카드. 원장(\`usage.jsonl\`, \`events.jsonl\`)은 읽을 수는 있고 쓸 수는 없다. 이 설정들은 전용 도구로 바꾸는 것이지 파일을 고쳐서 바꾸는 것이 아니다${shellReal ? ' (Write/Edit뿐 아니라 셸 리다이렉트·에디터도 마찬가지다)' : ''}. 도구 설치는 \`request_tool_install\`, 프로필·영입은 \`update_profile\`·\`hire_crew\`. 네 책상(\`vault/\`, \`skills/\`, 산출물)은 그대로 전부 네 것이다. - 사장이 Argo의 디자인·설정·기능을 고쳐 달라고 하면 앱 코드를 수정하지 마라 — 앱 자체는 안에서 고칠 수 없다고 설명하고 "설정 → 피드백"으로 전달하라고 안내하라. ## 너의 환경(Argo) — 막혔을 때 사장에게 정확히 안내하라 -- 너는 Argo 회사 안에서 일한다. 외부 도구(MCP)는 **회사별로** 연결된다 — 이 런타임은 컴퓨터의 Claude Code 설정(.claude.json, .mcp.json)을 설계상 상속하지 않는다(테넌트 격리). 그 파일들을 찾아 헤매지 마라.${caps.shell ? ` +- 너는 Argo 회사 안에서 일한다. 외부 도구(MCP)는 **회사별로** 연결된다 — 이 런타임은 컴퓨터의 Claude Code 설정(.claude.json, .mcp.json)을 설계상 상속하지 않는다(테넌트 격리). 그 파일들을 찾아 헤매지 마라.${shellReal ? ` - **오래 걸리는 명령(브라우저 자동화·대량 수집·빌드 등)은 전경에서 끝까지 기다려라.** Bash의 timeout을 넉넉히 지정하면 된다(밀리초, 최대 600000 = 10분). 예: 5분 예상이면 timeout: 420000. - **백그라운드(\`&\`·nohup·run_in_background)로 돌린 작업의 출력은 이 턴 안에서 회수하지 못하면 사라진다.** 턴이 끝나면 셸 세션이 닫혀 다음 턴에서 그 출력을 읽을 수 없다(네이티브 Claude Code와 다른 점 — 거기선 세션이 계속 살아 있다). 그러니 결과가 필요한 작업은 절대 셸 백그라운드로 던지고 턴을 끝내지 마라. - **10분으로 부족한 작업은 ${hasTools ? 'start_long_task 도구로 걸어라' : '작업을 쪼개 여러 턴으로 나누거나 결과를 파일로 쓰게 하고 다음 턴에 그 파일을 읽어라'}** — ${hasTools ? '대화를 막지 않고 턴 밖에서 끝까지 돌고, 완료되면 결과가 이 대화와 메신저로 배달된다. 셸 백그라운드와 달리 결과가 사라지지 않는다.' : ''}` : ''} @@ -830,8 +838,8 @@ export async function chat(wsId, agentSlug, userMsg, sessionId = null, { from = ? `${noCli.join('/')} is connected but its CLI is not installed on this computer — the ${noCli.join('/')} runner executes through the vendor CLI. Install it, or connect Claude (no install needed) in Settings → AI connections.` : `${noCli.join('/')} 자격은 연결됐지만 이 컴퓨터에 해당 CLI가 설치돼 있지 않습니다 — ${noCli.join('/')} 러너는 벤더 CLI로 실행됩니다. CLI를 설치하거나, 설치가 필요 없는 Claude를 설정 → AI 연결에서 연결해 주세요.`) : (lang === 'en' - ? 'No AI runner is connected. Connect one in Settings → AI connections (Claude, Codex, Gemini, Antigravity, GLM, Kimi, OpenRouter, or Grok), then try again.' - : 'AI 러너가 하나도 연결돼 있지 않습니다. 설정 → AI 연결에서 Claude·Codex·Gemini·Antigravity·GLM·Kimi·OpenRouter·Grok 중 하나를 연결한 뒤 다시 말을 걸어 주세요.')); + ? 'No AI runner is connected. Connect one in Settings → AI connections (Claude, Codex, Gemini, Antigravity, Kiro, GLM, Kimi, OpenRouter, or Grok), then try again.' + : 'AI 러너가 하나도 연결돼 있지 않습니다. 설정 → AI 연결에서 Claude·Codex·Gemini·Antigravity·Kiro·GLM·Kimi·OpenRouter·Grok 중 하나를 연결한 뒤 다시 말을 걸어 주세요.')); } const runner = resolved.runner; // 이번 턴까지 시도한 러너 목록 — 아래 두 실행 경로(CLI·SDK)의 인증 자가치유가 공유한다. diff --git a/src/cli-directives.mjs b/src/cli-directives.mjs index f63eb62f..9923ca86 100644 --- a/src/cli-directives.mjs +++ b/src/cli-directives.mjs @@ -21,6 +21,56 @@ import { callConnectorTool } from './connectors.mjs'; // 커넥터 단일 실행 쪽지: {"action":"mail","to":"슬러그","cc":["..."],"message":"..."} 커넥터: {"action":"tool","server":"gmail","tool":"search_threads","args":{…}} */ const BLOCK_RE = /```argo[ \t]*\r?\n([\s\S]*?)```/g; +/* 펜스 없는 변종 — 일부 CLI 러너는 답변을 **렌더해서** 내보내며 그 과정에서 코드펜스가 사라진다 + (kiro-cli 실측 2026-08-12: 백틱 0개, JSON은 한 줄로 렌더). 그러면 위 정규식이 영원히 매치되지 않아 + 지시가 **조용히 무동작**하고, 원시 JSON이 사장 화면에 그대로 남는다 — 이 파일이 없애려고 만들어진 + 바로 그 증상("크루가 예약했다고 말만 한다")이 러너만 바꿔 재발한다(러너 중립성 위반). + + 정규식이 아니라 스캔인 이유: 커넥터 지시는 `args`가 중첩 오브젝트라, 게으른 매치는 안쪽 `}`에서 + 멈추고 탐욕 매치는 뒤 문장까지 삼킨다. 첫 `{`부터 각 `}` 후보까지를 차례로 JSON.parse 해 + **처음 성공한 지점**을 취하면 중첩 깊이와 무관하게 정확하다(블록은 작아 비용 무시). + 오탐 방지: 파싱된 오브젝트에 `action` 키가 있을 때만 지시로 취급하고, 아니면 원문을 그대로 남긴다. + 산문에 이 형태가 우연히 나올 확률은 사실상 0이고, 나와도 텍스트가 보존되므로 손실이 없다. */ +const BARE_HEAD_RE = /^argo[ \t]*\r?\n[ \t]*(?=\{)/gm; +/** 스캔 상한 — `}` 후보마다 JSON.parse를 시도하므로 이론상 O(n²)다. 지시 블록은 실무상 1KB 미만이고 + 커넥터 인자를 넉넉히 봐도 이 상한이면 충분하다(분리 검수 4라운드 LOW: 266KB 답변에서 2.6초 블로킹). */ +const BARE_SCAN_LIMIT = 8 * 1024; + +/** 펜스 없는 블록 하나를 읽는다 — 반환 { obj, end } 또는 null(파싱 실패). start는 `{` 위치. */ +function readBareObject(src, start) { + const stop = Math.min(src.length, start + BARE_SCAN_LIMIT); + for (let i = src.indexOf('}', start); i !== -1 && i < stop; i = src.indexOf('}', i + 1)) { + const body = src.slice(start, i + 1); + try { + const obj = JSON.parse(body); + if (obj && typeof obj === 'object' && !Array.isArray(obj)) return { obj, end: i + 1, body }; + } catch { /* 아직 닫히지 않았다 — 다음 `}` 후보로 */ } + } + return null; +} + +/** 코드펜스 구간 [시작, 끝) 목록 — 펜스 **안**의 지시 형태는 지시가 아니라 예시다(설명·문서 작성). + 이걸 실행하면 크루가 "이렇게 쓰면 됩니다"라고 보여준 예시가 실제 쪽지·예약이 된다 + (분리 검수 4라운드 MEDIUM). BLOCK_RE가 이미 소비한 ```argo는 이 시점에 없으므로, 남은 펜스는 + 전부 일반 코드블록이다. + 줄 단위로 여닫는 이유(분리 검수 5라운드 MEDIUM): `/```[\s\S]*?```/`는 **미닫힘 펜스를 뒤 펜스와 + 짝지어** 그 사이의 진짜 지시를 통째로 삼켰다(재현됨). ~~~ 펜스도 함께 인식한다. + 미닫힘 펜스는 구간을 만들지 않는다 — 파일 끝까지 보호 구간으로 늘리면, 답변 끝에 오는 실제 + 지시(관행상 마지막에 붙는다)가 앞쪽의 깨진 펜스 하나 때문에 무시된다. 덜 나쁜 쪽을 택했다. */ +function fencedRanges(src) { + const out = []; + let open = null; + let pos = 0; + for (const line of src.split('\n')) { + const m = /^[ \t]*(`{3,}|~{3,})/.exec(line); + if (m) { + if (!open) open = { mark: m[1][0], start: pos }; + else if (m[1][0] === open.mark) { out.push([open.start, pos + line.length]); open = null; } + } + pos += line.length + 1; + } + return out; +} /** 커넥터 결과 주입 상한(바이트) — **한 턴 전체 예산**이다(블록이 여럿이면 앞에서부터 나눠 쓴다). 근거: 후속 턴 프롬프트는 러너 CLI에 argv로 실린다. Windows CreateProcess의 32,767자가 3플랫폼 @@ -56,19 +106,40 @@ export function connectorContentText(content) { /** 답변에서 지시를 걷어낸다(순수 함수 — 단위 테스트용). 반환: { clean, directives, bad } 파싱 실패 블록은 bad로 돌려 **조용히 삼키지 않는다** — 크루가 형식을 틀렸는데 아무 일도 - 안 일어나면 그게 곧 "예약했다고 말만 하는" 할루시네이션이 된다. */ + 안 일어나면 그게 곧 "예약했다고 말만 하는" 할루시네이션이 된다. + ⚠ 펜스 없는 변종(아래)은 bad를 쓰지 않는다: 펜스라는 명시 표지가 없어 "지시하려다 틀린 것"과 + "그냥 산문"을 구분할 수 없다. 그래서 파싱 실패·action 부재는 **원문을 그대로 남기는 쪽**으로 + 처리한다(사장이 원시 JSON을 보면 형식 오류를 알 수 있다 — 조용한 소실보다 낫다). */ export function parseDirectives(text) { const src = String(text ?? ''); const directives = []; const bad = []; - const clean = src.replace(BLOCK_RE, (_m, body) => { + const take = (body) => { try { const d = JSON.parse(body.trim()); if (d && typeof d === 'object' && !Array.isArray(d)) directives.push(d); else bad.push('블록이 JSON 오브젝트가 아님'); } catch (e) { bad.push(String(e.message || e).slice(0, 120)); } return ''; - }); + }; + let clean = src.replace(BLOCK_RE, (_m, body) => take(body)); + // 펜스 없는 변종 — 잘라내기는 **뒤에서 앞으로**(앞에서 자르면 인덱스가 밀린다) 하되, + // 수집한 지시는 **원문 순서로** 되돌려 붙인다. 지시는 순차 실행되므로 순서가 뒤집히면 + // "쪽지 보낸 뒤 예약"이 "예약한 뒤 쪽지"가 된다. + const bareFound = []; + const fences = fencedRanges(clean); + const inFence = (i) => fences.some(([a, b]) => i >= a && i < b); + const heads = [...clean.matchAll(BARE_HEAD_RE)].reverse(); + for (const h of heads) { + if (inFence(h.index)) continue; // 펜스 안 = 예시. 실행하지 않고 원문 그대로 둔다 + const braceAt = clean.indexOf('{', h.index); + if (braceAt === -1) continue; + const found = readBareObject(clean, braceAt); + if (!found || typeof found.obj.action !== 'string') continue; // action 없으면 지시로 보지 않는다(원문 보존) + bareFound.push(found.obj); + clean = clean.slice(0, h.index) + clean.slice(found.end); + } + directives.push(...bareFound.reverse()); return { clean: clean.replace(/\n{3,}/g, '\n\n').trim(), directives, bad }; } diff --git a/src/oneshot.mjs b/src/oneshot.mjs index 2222e1f9..276dc0e9 100644 --- a/src/oneshot.mjs +++ b/src/oneshot.mjs @@ -31,8 +31,8 @@ export async function runOneShot(wsId, prompt, opts = {}) { ? `${noCli.join('/')} is connected but its CLI is not installed on this computer — install it, or connect Claude (no install needed) in Settings → AI connections.` : `${noCli.join('/')} 자격은 연결됐지만 이 컴퓨터에 해당 CLI가 설치돼 있지 않습니다 — CLI를 설치하거나, 설치가 필요 없는 Claude를 설정 → AI 연결에서 연결해 주세요.`) : (lang === 'en' - ? 'No AI runner is connected — connect Claude, Codex, Gemini, Antigravity, GLM, Kimi, OpenRouter, or Grok in Settings → AI connections.' - : 'AI 러너가 하나도 연결돼 있지 않습니다 — 설정 → AI 연결에서 Claude·Codex·Gemini·Antigravity·GLM·Kimi·OpenRouter·Grok 중 하나를 연결해 주세요.')); + ? 'No AI runner is connected — connect Claude, Codex, Gemini, Antigravity, Kiro, GLM, Kimi, OpenRouter, or Grok in Settings → AI connections.' + : 'AI 러너가 하나도 연결돼 있지 않습니다 — 설정 → AI 연결에서 Claude·Codex·Gemini·Antigravity·Kiro·GLM·Kimi·OpenRouter·Grok 중 하나를 연결해 주세요.')); } const runner = resolved.runner; let hangGuard = null; // SDK 경로 hang 상한 타이머 — 아래 finally에서 항상 해제 diff --git a/src/persona.mjs b/src/persona.mjs index e541a552..3960211c 100644 --- a/src/persona.mjs +++ b/src/persona.mjs @@ -162,8 +162,8 @@ export async function createAgentFromPrompt(wsId, oneLiner, { name, team } = {}) // AI가 아예 응답을 못 준 경우만 진짜 실패. 형식이 어긋난 건 아래에서 복원한다(생성 실패로 두지 않는다). if (!md) { throw new Error(lang === 'en' - ? 'AI connection is needed — connect any runner (Claude, Codex, Gemini, Antigravity, GLM, Kimi, OpenRouter, or Grok) in Settings → AI connections to hire.' - : 'AI 연결이 필요합니다 — 설정 → AI 연결에서 아무 러너나(Claude·Codex·Gemini·Antigravity·GLM·Kimi·OpenRouter·Grok) 연결하면 영입할 수 있어요.'); + ? 'AI connection is needed — connect any runner (Claude, Codex, Gemini, Antigravity, Kiro, GLM, Kimi, OpenRouter, or Grok) in Settings → AI connections to hire.' + : 'AI 연결이 필요합니다 — 설정 → AI 연결에서 아무 러너나(Claude·Codex·Gemini·Antigravity·Kiro·GLM·Kimi·OpenRouter·Grok) 연결하면 영입할 수 있어요.'); } // 관대한 필드 복원 — frontmatter(닫는 --- 없어도)·본문 H1("# 이름 — 역할")·입력에서 긁는다. diff --git a/src/runners.mjs b/src/runners.mjs index caabd42d..7360e271 100644 --- a/src/runners.mjs +++ b/src/runners.mjs @@ -10,12 +10,14 @@ import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { monthCostByRunner } from './usage.mjs'; // usage는 workspace만 의존 — 순환 없음 import { exec, exists, scrubServerSecrets } from './runners/shared.mjs'; import { RUNNERS, RUNNER_AUTH, hostOptInAllowed, isCliRunner, pickRunner, oauthFormatError } from './runners/catalog.mjs'; import { codexHome, codexCmd, importCodexAuth, recoverCodexAuth, writeCodexTurnConfig, codexEffortArgs, codexSandboxArgs } from './runners/codex.mjs'; import { geminiCmd, writeGeminiTurnSettings } from './runners/gemini.mjs'; +import { kiroCmd, kiroEffortArgs, kiroScrub, writeKiroTurnAgent, removeKiroTurnAgent } from './runners/kiro.mjs'; import { openRoots } from './workroots.mjs'; // 파일 반경 단일 진실(codex·gemini·antigravity 공유) import { loadSecrets, credType, maskCred } from './runners/creds.mjs'; @@ -38,6 +40,7 @@ export { importCodexAuth, recoverCodexAuth, writeCodexTurnConfig, } from './runners/codex.mjs'; export { provisionGeminiCli, probeGeminiOAuth, probeGeminiHostOAuth } from './runners/gemini.mjs'; +export { kiroCmd, kiroScrub, kiroTools, kiroEffortArgs, KIRO_EFFORTS, kiroDeniedPaths, writeKiroTurnAgent, removeKiroTurnAgent } from './runners/kiro.mjs'; export { accountScope, loadRunnerCred, saveRunnerCred, clearRunnerCred, seedRunnerCreds, maskCred, normalizePastedCred, runnerCredEnv, sdkEnvFor, kimiEnv, glmEnv, verifyRunnerCred, @@ -179,6 +182,34 @@ export async function externalExec({ runner, model, cwd, prompt, timeoutMs = 300 .catch((e) => { if (process.env.ARGO_DEBUG_AGY) console.error('[debug agy]', JSON.stringify({ code: e.code, killed: e.killed, signal: e.signal, so: String(e.stdout ?? '').slice(-60), se: String(e.stderr ?? '').slice(-120) })); throw cliTurnFailure(e, 'antigravity', Date.now() - t0, timeoutMs, { stage: 'exec', kind }); }); return stdout.replace(/^[IWEF]\d{4} \d{2}:\d{2}:\d{2}\.\d+\s+.*$/gm, '').trim(); // glog 제거 — 시각 필드까지 요구(분리 검수 M2: 'E1234 …'로 시작하는 정상 응답 오삭제 방지) } + if (runner === 'kiro') { + // BYOA 3호 — kiro-cli 래핑. 자격은 CLI 로그인(IAM Identity Center / Builder ID)이라 env·격리 홈 + // 반입이 없다(host 옵트인 전용, cred는 항상 null 경유 — antigravity와 같은 구조). + // 권한은 **턴별 에이전트 설정**으로 준다: 전역 설정(사용자 MCP 서버·에이전트)을 차단하면서 + // 능력(caps)→도구 목록, 불변 보안 경계→deniedPaths를 한 파일에 싣는다(src/runners/kiro.mjs). + // 설정 이름에 턴 고유값을 넣는 이유: 같은 회사에서 두 크루가 동시에 답할 수 있어 고정 이름이면 + // 한 턴의 finally 삭제가 다른 턴의 실행 중 설정을 지운다. + const cmd = await kiroCmd(); + const name = `argo-${randomUUID().slice(0, 8)}`; + await writeKiroTurnAgent(cwd, { caps, name }); + try { + const { stdout } = await exec(cmd.file, [ + ...cmd.args, + 'chat', '--no-interactive', + '--agent', name, + ...(model ? ['--model', model] : []), + ...kiroEffortArgs(effort), // 크루별 추론 강도 — kiro도 지원(--effort low..max, 실측 2026-08-12) + '--wrap', 'never', // 터미널 폭 기준 줄바꿈 삽입 금지 — 답변 원문을 그대로 받는다 + '--', prompt, // 프롬프트가 '---'(카드 frontmatter)로 시작해도 플래그로 오해하지 않도록(codex와 같은 이유) + ], { cwd, timeout: timeoutMs, maxBuffer: 32e6, ...(signal ? { signal } : {}), env: { ...scrubServerSecrets(process.env, 'kiro'), ...(cred?.env ?? {}), NO_COLOR: '1' } }) + .catch((e) => { throw cliTurnFailure(e, 'kiro', Date.now() - t0, timeoutMs, { stage: 'exec', kind }); }); + // 최종 답변만 남긴다 — kiro-cli에는 codex `--output-last-message` 같은 분리 플래그가 없어 + // 렌더 출력에서 마지막 어시스턴트 블록을 취한다(kiroScrub 주석에 계약과 알려진 한계). + return kiroScrub(stdout); + } finally { + await removeKiroTurnAgent(cwd, name); // 회사 금고에 턴 잔재를 남기지 않는다 + } + } throw new Error(`알 수 없는 외부 러너: ${runner}`); } diff --git a/src/runners/catalog.mjs b/src/runners/catalog.mjs index cbc25e5e..35c83ded 100644 --- a/src/runners/catalog.mjs +++ b/src/runners/catalog.mjs @@ -64,6 +64,28 @@ export const RUNNERS = { { id: 'gpt-oss-120b-medium', label: 'GPT-OSS 120B (Antigravity)' }, ], }, + kiro: { + name: 'Kiro', kind: 'cli', + // BYOA 3호(2026-08-12) — AWS Kiro CLI(`kiro-cli`) 래핑. 자격은 IAM Identity Center / Builder ID + // 로그인이고 토큰은 로컬 데이터 저장소에 있다 — 붙여넣을 API 키 표면이 없어 host 옵트인 전용 + // (antigravity와 같은 계열이나, 로그인 여부를 `whoami`로 **실제 확인**할 수 있어 낙관 authed가 아니다). + // 모델 목록 = `kiro-cli chat --list-models --format json` 실측(2.17.0)에서 고른 10종. + // 카탈로그 규칙(실턴 통과분만 등재) 준수 — 아래 전부 2026-08-12 원샷 왕복 10/10 통과 실측. + // 첫 항목이 러너 전환 기본값: `auto`는 kiro-cli 자신의 기본값이라 어떤 계정에서도 돈다 + // (gemini·openrouter 카탈로그와 같은 원칙 — 앞자리에 권한·잔액 의존 모델을 두지 않는다). + models: [ + { id: 'auto', label: 'Auto (Kiro)' }, + { id: 'claude-opus-5', label: 'Claude Opus 5 (Kiro)' }, + { id: 'claude-sonnet-5', label: 'Claude Sonnet 5 (Kiro)' }, + { id: 'claude-haiku-4.5', label: 'Claude Haiku 4.5 (Kiro)' }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra (Kiro)' }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna (Kiro)' }, + { id: 'glm-5', label: 'GLM-5 (Kiro)' }, + { id: 'deepseek-3.2', label: 'DeepSeek V3.2 (Kiro)' }, + { id: 'minimax-m2.5', label: 'MiniMax M2.5 (Kiro)' }, + { id: 'qwen3-coder-next', label: 'Qwen3 Coder Next (Kiro)' }, + ], + }, kimi: { name: 'Kimi', kind: 'sdk-compat', models: [ @@ -212,6 +234,15 @@ export const RUNNER_AUTH = { // deviceCode 플래그로 라우트가 기기 코드 분기를 탄다. 붙여넣을 코드가 없으니 oauthPasteable은 false. // 접두사는 걸지 않는다(GLM·Kimi 관례) — 키 형식이 바뀌면 저장부터 막혀 사용자가 손 쓸 데가 없다. 판정은 verifyRunnerCred가. grok: { methods: ['apikey', 'oauth'], apikeyPrefix: '', oauthPasteable: false, webConnect: true, deviceCode: true, keyUrl: 'https://console.x.ai' }, + // kiro: 자격이 CLI 로그인(IAM Identity Center / Builder ID)이고 토큰은 로컬 데이터 저장소에 있어 + // 붙여넣기·API키·웹 브리지 전부 불가 — host 옵트인이 유일한 경로(antigravity와 같은 구조). + // antigravity와 다른 점: 로그인 여부를 `kiro-cli whoami`로 **실제 판정**할 수 있어(실측: 로그인 + // "Logged in with IAM Identity Center"/exit 0, 미로그인 "Not logged in"/exit 1) authed가 낙관값이 + // 아니다 — 그래서 antigravity처럼 맨 끝일 필요가 없고 authUnknown도 두지 않는다. + // connect(로그인 대행)는 아직 두지 않는다: `kiro-cli login`이 라이선스 종류(free/pro)·IdP URL을 + // 되묻는 대화형일 수 있어(미검증) detached spawn이 조용히 멈출 위험이 있다 — 터미널 로그인 안내가 + // 정직하다. 기기 코드(`login --use-device-flow`)는 존재하므로 후속에서 grok과 같은 배관으로 붙인다. + kiro: { methods: ['oauth'], apikeyPrefix: '', oauthPasteable: false, hostUsable: true, keyUrl: 'https://kiro.dev/downloads' }, // antigravity: 자격이 OS 키링(파일 아님)이라 붙여넣기·API키·웹 브리지 전부 불가 — 호스트 로그인 // 옵트인이 유일한 경로다(agy가 GEMINI_API_KEY를 무시함은 공식 문서 확인). keyUrl은 설치·로그인 안내. // **정의 순 = pickRunner 자동 선택 순 — 반드시 맨 끝**: 키링이라 로그인 여부를 파일로 판정할 수 없는 diff --git a/src/runners/exec.mjs b/src/runners/exec.mjs index d5077e9e..9d7287ff 100644 --- a/src/runners/exec.mjs +++ b/src/runners/exec.mjs @@ -69,6 +69,17 @@ export function apiError(e, runner = null) { + 'Antigravity timed out — likely agy is not logged in on this machine (run agy in a terminal and sign in with Google), ' + 'or the task exceeded the time limit.'); } + // kiro-cli 미로그인 — 비대화(`--no-interactive`)에서도 브라우저 로그인을 열려 시도하고, 아무도 + // 승인하지 않아 `error: OAuth error: Auth portal timed out`(exit 1)으로 죽는다(격리 HOME 실측 + // 2026-08-12: stdout엔 "Opening browser..." 스피너만, stderr에 이 문구). 상주 서비스·헤드리스에서는 + // 브라우저 자체가 없어 같은 자리로 떨어진다. 원문을 그대로 두면 사용자는 자기 네트워크를 의심한다. + // **runner 게이트**: antigravity M1과 같은 이유 — 크루가 셸로 실행한 명령 출력(stdout)에 같은 + // 문구가 섞이는 경로를 배제하고, kiro 실행 경로에서만 발화한다. + // 문구의 "not logged in"은 AUTH_ERR_RE(chat.mjs) 계약 — 자가치유(남은 가용 러너 순차 폴백)를 살린다. + if (runner === 'kiro' && /auth portal timed out|OAuth error/i.test(raw)) { + return new Error('Kiro 로그인이 이 컴퓨터에 없습니다(not logged in). 터미널에서 kiro-cli login 을 실행해 로그인한 뒤 다시 시도해 주세요. ' + + 'Kiro is not logged in on this machine — run kiro-cli login in a terminal, then retry.'); + } const m = raw.match(/"message"\s*:\s*"([^"]+)"/); return new Error(maskKeyLike(m ? m[1] : `러너 실행 실패 (exit ${e.code ?? '?'}): ${String(e.stderr ?? e.message).replace(/\s+/g, ' ').slice(-160)}`)); } @@ -92,13 +103,21 @@ export async function detectRunners(force = false) { if (!force && cache && Date.now() - cacheAt < DETECT_CACHE_MS) return cache; await ensureCliPath(); // GUI 기동 PATH 보강 — homebrew/npm 전역 CLI 오탐 방지 const home = homedir(); - const [codexV, codexManaged, geminiV, geminiManaged, agyV, agyLocalBin, codexAuth, geminiAuth, claudeCredFile, claudeCfgLogin] = await Promise.all([ + const [codexV, codexManaged, geminiV, geminiManaged, agyV, agyLocalBin, kiroV, kiroLocalBin, kiroAuth, codexAuth, geminiAuth, claudeCredFile, claudeCfgLogin] = await Promise.all([ exec('codex', ['--version']).then((r) => r.stdout.trim(), () => null), exists(codexManagedBin()), // 관리본(자동 조달)도 설치로 취급 — PATH 없이도 돈다 exec('gemini', ['--version']).then((r) => r.stdout.trim(), () => null), exists(geminiManagedEntry()), exec('agy', ['--version']).then((r) => r.stdout.trim(), () => null), exists(join(home, '.local', 'bin', 'agy')), // 공식 인스톨러 고정 경로 — GUI PATH 누락 대비 + exec('kiro-cli', ['--version']).then((r) => r.stdout.trim(), () => null), + exists(join(home, '.local', 'bin', 'kiro-cli')), // agy와 같은 이유(공식 인스톨러 고정 경로) + // kiro 자격 — 파일이 아니라 CLI에 **직접 묻는다**(토큰이 로컬 데이터 저장소/OS 보관이라 파일 + // 존재로는 판정 불가). 실측 2026-08-12: 로그인 "Logged in with IAM Identity Center"(exit 0), + // 미로그인 "Not logged in"(exit 1). `/Logged in/i`는 **"Not logged in"에도 매칭**되므로 + // 줄머리 앵커(^)가 필수다 — 이 한 글자가 미로그인을 '연결됨'으로 뒤집는다. + // 비용 ~1.3초(실측)지만 이 Promise.all은 병렬이고 상위 캐시가 10분이라 체감 없음. 미설치면 즉시 reject. + exec('kiro-cli', ['whoami']).then((r) => /^Logged in/im.test(String(r.stdout ?? '')), () => false), exists(join(home, '.codex', 'auth.json')), exists(join(home, '.gemini', 'oauth_creds.json')), @@ -127,6 +146,9 @@ export async function detectRunners(force = false) { // apiError 매핑("timeout waiting for response" → 로그인 안내)이 잡는다. host 마커 invalid 판정 // (runnerStatus)이 authed=false면 옵트인 직후부터 "재연결 필요"로 오표시되는 것을 막는 값이기도 하다. antigravity: { installed: !!agyV || agyLocalBin, authed: !!agyV || agyLocalBin, authUnknown: !!agyV || agyLocalBin }, // authUnknown(설치 시): UI는 '로그인됨' 단정 대신 '확인 불가'를 그린다(거짓 유효 표기 금지 — 분리 검수 H1c) + // kiro: antigravity와 같은 host 옵트인 전용이지만 **authed가 실측값**이다(whoami) — authUnknown을 + // 두지 않는 이유이자, RUNNER_AUTH에서 맨 끝일 필요가 없는 이유(낙관값 선점 문제가 없다). + kiro: { installed: !!kiroV || kiroLocalBin, authed: (!!kiroV || kiroLocalBin) && kiroAuth }, }; cacheAt = Date.now(); return cache; diff --git a/src/runners/kiro.mjs b/src/runners/kiro.mjs new file mode 100644 index 00000000..026eef1b --- /dev/null +++ b/src/runners/kiro.mjs @@ -0,0 +1,309 @@ +// Kiro 러너 — kiro-cli(AWS Kiro CLI) 래핑. 실행 파일 해석·턴별 격리 에이전트 설정·최종 답변 추출. +// (codex/gemini/antigravity와 같은 CLI 래핑 계열 — 세 번째 배관을 만들지 않는다) +// +// 왜 CLI 래핑인가: kiro-cli의 자격은 AWS IAM Identity Center / Builder ID 로그인이고, 토큰은 +// 로컬 데이터 저장소(macOS: ~/Library/Application Support/kiro-cli)에 있다. 붙여넣을 수 있는 +// API 키 표면이 없으므로 codex·antigravity처럼 **호스트 로그인을 빌리는** 경로가 유일하다. +// +// 실측 기반(kiro-cli 2.17.0, macOS, 2026-08-12): +// | 항목 | 실측 | +// |------------|---------------------------------------------------------------| +// | 원샷 | `chat --no-interactive [--model] [--effort]` exit 0 | +// | 데몬 스폰 | TTY 없음·stdin 닫힘·최소 env에서도 정상(상주 서비스 경로 확인) | +// | 모델 목록 | `chat --list-models --format json` → 19종 | +// | 자격 판정 | `whoami` → 로그인 "Logged in with IAM Identity Center"(exit 0) / +// | | 미로그인 "Not logged in"(exit 1) | +// | 미로그인 턴 | 브라우저를 열려다 `error: OAuth error: Auth portal timed out`(exit 1) | +// | 설정 격리 | `/.kiro/agents/.json` + `--agent `로 전역 설정 차단 | + +import { mkdir, writeFile, readdir, rm, rmdir, realpath } from 'node:fs/promises'; +import { dirname, join, basename, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { exec, exists } from './shared.mjs'; + +/** 홈은 env로만(HOME/USERPROFILE) — node:os homedir()를 **동적 join**(map/flatMap 인자)에 쓰면 Next + 추적기가 빌드타임 부분평가로 홈 루트를 통글롭해 Windows 릴리스 빌드가 죽는다. 아래 하드 홈 목록이 + 바로 그 동적 join이므로 permission-gate.mjs와 같은 방식을 쓴다(그 파일 헤더 주석의 불변식). */ +const homeDir = () => process.env.HOME ?? process.env.USERPROFILE ?? ''; + +/** 실행 중인 Argo 코드 루트 — src/runners/의 조부모. workroots.mjs·permission-gate.mjs의 APP_ROOT와 + 같은 값(그 둘은 src/ 기준이라 '..' 한 번, 여기는 src/runners/라 두 번)이다. 인자로 주입할 수 있게 + 기본값으로만 둔다 — 테스트가 임시 트리로 갈아끼울 수 있어야 경계 계산을 단위로 잠근다. */ +const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** 공식 인스톨러 고정 경로 — GUI 최소 PATH(데스크톱 사이드카)에서 PATH 감지가 실패할 때의 폴백. + 상수 join이라 Next 추적기 문제(위 homeDir 주석)와 무관하지만, 홈 출처를 이 파일 안에서 + 하나로 유지하려고 같은 헬퍼를 쓴다. */ +const KIRO_LOCAL_BIN = () => join(homeDir(), '.local', 'bin', 'kiro-cli'); + +/** kiro-cli 실행 파일 — PATH 설치본 우선, 공식 인스톨러 고정 경로 폴백. + codex/gemini와 달리 **자동 조달은 하지 않는다**(antigravity와 같은 판단): 공식 인스톨러가 + 셸 프로파일을 수정하고 데스크톱 앱까지 함께 설치하는 부작용이 있어 명시 설치 안내가 정직하다. */ +export async function kiroCmd() { + const onPath = await exec('kiro-cli', ['--version']).then(() => true, () => false); + if (onPath) return { file: 'kiro-cli', args: [] }; + const local = KIRO_LOCAL_BIN(); + if (await exists(local)) return { file: local, args: [] }; + // Windows 공식 설치 경로 후보 — PATH 미등록 GUI 기동 대비. 실기기 미검증(설계 문서에 명시). + if (process.platform === 'win32' && process.env.LOCALAPPDATA) { + const winBin = join(process.env.LOCALAPPDATA, 'kiro-cli', 'bin', 'kiro-cli.exe'); + if (await exists(winBin)) return { file: winBin, args: [] }; + } + throw new Error('Kiro CLI(kiro-cli)가 설치되어 있지 않습니다. https://kiro.dev 에서 설치한 뒤 터미널에서 kiro-cli login 으로 로그인해 주세요. ' + + 'Kiro CLI (kiro-cli) is not installed — install it and run kiro-cli login, then retry.'); +} + +/* ─── 최종 답변 추출 ─── + kiro-cli에는 codex의 `--output-last-message` 같은 최종 메시지 분리 플래그가 없다. 대신 렌더러가 + **어시스턴트 메시지의 첫 줄에만 `> ` 접두사**를 붙인다(도구 추적 줄은 무접두사). 그래서 마지막 + `> ` 블록을 취하면 codex `--output-last-message`와 같은 의미가 된다 — 실측 6종(도구 사용 전후· + 여러 줄·마크다운 인용 포함)에서 정확히 일치. + 마크다운 인용(`> foo`)은 렌더러가 `│ `로 그려 접두사와 충돌하지 않는다(실측). + ⚠ 알려진 한계(실증): 최종 답변 **2행 이후**의 코드블록에 `> `로 시작하는 줄이 있으면 그 줄을 + 새 메시지 시작으로 오인해 앞부분이 절단된다(렌더 결과에는 코드펜스가 남지 않아 "코드블록 안"을 + 판별할 수단이 없다). 근본 해법은 `kiro-cli acp`(JSON-RPC 구조화 스트림)로 옮기는 것 — + docs/kiro-runner-design.md의 후속 항목. */ +const ANSI_RE = /\u001B\[[0-9;?]*[A-Za-z]|\u001B\[K/g; +/* 도구 추적 줄 — 이 줄에서 시작하는 텍스트는 답변이 아니라 CLI의 진행 표시다. + 실측된 형태(2026-08-12, 분리 검수 4·5라운드): 단건 `Reading file: <경로>`, 디렉터리 + `Reading directory: <경로>`, 배치 `Batch fs_read` / `↱ Operation 3: …`, 쓰기 + `I'll create the following file: <경로>` / `Creating: <경로>` / 번호 diff, 완료 ` - Completed in 0.1s`. + ⚠ 각 규칙은 **경로나 형식을 함께 요구**한다(분리 검수 6라운드): `Reading`만 보면 산문 + "Reading files is safe."가, `Creating:`만 보면 "Creating: a plan"이, ` - Completed in`만 보면 + " - Completed in 3 days"가 통째로 지워진다 — 추적 제거가 정상 답변을 파괴하는 방향이다. */ +const TRACE_HEAD_RE = /^(?:\u21b1\s*)?(?:Operation \d+:\s*)?(?:Reading (?:file|directory|image)s?:\s*\S|Batch fs_\w+|(?:Creating|Updating):\s*\S*[/\\]|I['\u2019]ll (?:create|update) the following file:|\s*[+-]\s{2,}\d+:| - Completed in \d+(?:\.\d+)?s\b)/; +/* 거부 블록 — `Command fs_read is rejected because …:` 다음 줄부터 **들여쓴 경로 목록**이 이어진다. + 그 목록은 금지 파일의 전체 경로라 답변에 남으면 안 되는데, 형태가 정상 마크다운 중첩 불릿과 같다 + (` - /path`). 그래서 단독 줄 규칙으로 두면 "폴더 아래 파일 보여줘" 같은 상용 답변의 중첩 목록이 + 조용히 사라진다(분리 검수 6라운드 CRITICAL 실증 — 부모 한 줄만 남았다). + → **문맥 의존**으로 바꾼다: 거부 머리 줄 직후 연속 구간에서만 이 목록을 걷어낸다. */ +const REJECT_HEAD_RE = /^Command \w+ is rejected\b/; +const DENY_RULE_RE = /^\s{2,}- \S+$/; + +/* 어시스턴트 메시지 시작 표지는 `> `인데, **줄머리에만 있는 게 아니다**(분리 검수 4·5라운드 실증): + 거부·배치 경로에서 kiro-cli가 다음 메시지를 추적 텍스트 **끝에 개행 없이** 붙인다. + `Reading file: … (using tool: read)> 차단되었습니다…` + `Reading directory: … (using tool: read, max depth: 0, …)> …` ← 인자형 + `↱ Operation 3: Reading file: /x, all lines> …` ← (using tool:) 자체가 없다 + 접합부 문자열을 열거하는 방식은 4라운드에 한 번 실패했다(형태가 더 있었다). 그래서 열거를 늘리는 + 대신 **추적 줄을 먼저 분리**한다: 위 머리 패턴으로 식별된 줄에서만 첫 `> ` 앞을 잘라 개행을 넣는다. + 식별된 줄 안에서만 자르므로 산문의 `> `(비교 연산·인용)를 표지로 오인하지 않는다. */ +const isTraceLine = (line) => TRACE_HEAD_RE.test(line) || line.includes('(using tool:'); + +function splitTraceGlue(plain) { + return plain.split('\n').map((line) => { + if (!isTraceLine(line)) return line; + const i = line.indexOf('> '); + return i === -1 ? line : `${line.slice(0, i)}\n${line.slice(i)}`; + }).join('\n'); +} + +/** stdout → 최종 답변(순수). ANSI 제거 후 마지막 어시스턴트 블록만 남긴다. + 접두사가 아예 없으면(예상 외 형식·미래 렌더러 변경) **전문을 반환**한다 — 조용한 빈 답변보다 + 잡음 섞인 답변이 낫다(gemini 스크럽이 통삭제로 정상 응답을 지웠던 계열의 반대 방향). + (export: 회귀 테스트용 — 순수 함수) */ +export function kiroScrub(stdout) { + const lines = splitTraceGlue(String(stdout ?? '').replace(ANSI_RE, '')).split('\n'); + let last = -1; + for (let i = 0; i < lines.length; i += 1) if (lines[i].startsWith('> ')) last = i; + // 표지 부재(예상 외 형식·미래 렌더러 변경)면 전문을 쓴다 — 통삭제로 정상 응답을 지우는 방향 + // (gemini 스크럽 선례)은 피한다. 어느 쪽이든 아래에서 추적 줄을 걷어낸다. + const picked = last < 0 ? lines : [lines[last].slice(2), ...lines.slice(last + 1)]; + // 추적 줄 제거를 **항상** 적용한다(분리 검수 5라운드 권고): 거부·배치 경로에서는 추적이 메시지 + // **뒤에도** 붙어(`Command fs_read is rejected …` + 규칙 목록) 표지 판정만으로는 답변에 남는다. + // 그 잔재는 금지 파일의 전체 경로를 담고, 대화 정본(chats/)에 저장돼 다음 턴 맥락으로 재주입된다. + // 거부 규칙 목록만은 **문맥 의존**이다(6라운드 CRITICAL): 거부 머리 줄 직후 연속 구간에서만 걷어내 + // 정상 답변의 중첩 마크다운 불릿을 지키다. + const kept = []; + let inReject = false; + for (const line of picked) { + if (REJECT_HEAD_RE.test(line)) { inReject = true; continue; } + if (inReject && DENY_RULE_RE.test(line)) continue; // 빈 줄에서 구간을 닫는다(7R LOW: 거부 블록 뒤 정상 불릿 보호) + inReject = false; + if (!isTraceLine(line)) kept.push(line); + } + return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +/* ─── 추론 강도 ─── + `--effort low|medium|high|xhigh|max`(도움말 실측). Argo effort 값과 이름이 같아 통과시키면 되고, + 모르는 값은 **넘기지 않는다** — CLI가 인자 오류로 죽으면 그 턴이 통째로 실패한다(codex와 같은 방어). */ +export const KIRO_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']; +/** effort → 인자(순수). 빈 값·미지값은 빈 배열(러너 기본값). (export: 회귀 테스트용) */ +export const kiroEffortArgs = (effort) => (KIRO_EFFORTS.includes(String(effort ?? '')) ? ['--effort', String(effort)] : []); + +/* ─── 능력(caps) → 도구 목록 ─── + **경계가 증명된 도구만 준다**(fail-closed). deniedPaths는 `read`·`write`에만 서고, 다른 도구는 + 같은 파일을 그냥 연다 — 실측(2026-08-12, 분리 검수 2라운드 CRITICAL): + · `grep` 금고 `connections.json`·형제 회사·WS_ROOT 직속 도트의 **매치 줄 내용을 그대로 반환**. + 같은 턴·같은 설정에서 `read`로 같은 파일을 열면 정상 DENY된다(대조군) — 즉 경계가 + read/write 전용이다. grep·glob은 `toolsSettings` 키가 공식 스키마에 없어 막을 수도 없다. + · `glob` `~/.codex` 7,766개·`~/.claude` 527개 파일 열거 성공(경로 유출). + · `shell` `cat <금고>`·`cat ../<타사>/notes.md`·`echo > usage.jsonl`(원장 개조) 전부 성공. + `deniedCommands`(정규식)는 강제되지만(실측), 명령 문자열 정규식은 `$(…)`·`sh -c`· + 인터프리터 경유로 우회가 자유롭고 permission-gate처럼 **인자 경로를 판정하는 백스톱이 + 없다**. `denyByDefault`는 문서에 있으나 무시된다(실측: 화이트리스트 밖 명령이 실행됨). + 그래서 이 러너의 도구는 read·write(+브라우저 능력 시 web_fetch)로 한정한다. 셸 부재는 이 레포에 + 선례가 있다 — gemini CLI 러너도 비대화(`--approval-mode auto_edit`)에서 셸이 실행되지 않고 + (`externalExec` gemini 분기 주석) 그 상태로 운영된다. + caps.shell이 켜져 있어도 이 러너는 셸을 주지 않는다 — UI가 정직 표기한다. */ +const BASE_TOOLS = ['read', 'write']; + +/** caps → { tools, allowedTools }(순수). allowedTools는 tools와 같게 준다 — + 비대화(`--no-interactive`)에서는 **포괄 신뢰가 없으면 도구 호출이 전부 거부**된다(실측: + toolsSettings.allowedPaths만 두면 반경 안쪽 쓰기까지 거부). 경로 강제는 deniedPaths가 한다. + fail-closed: caps 미전달(oneshot 등)이면 브라우저를 끈다. + (export: 회귀 테스트용 — 순수 함수) */ +export function kiroTools(caps) { + const tools = [...BASE_TOOLS]; + // web_fetch는 사장이 브라우저 능력을 켠 경우만. 읽을 수 있는 데이터가 이미 deniedPaths로 + // 제한되므로 반출 범위도 그만큼 제한된다(URL 차단 규칙 web_fetch.blocked는 미검증 — 쓰지 않는다). + if (caps?.browser) tools.push('web_fetch'); + return { tools, allowedTools: [...tools] }; +} + +/* ─── 불변 보안 경계 → deniedPaths ─── + kiro-cli의 비대화 권한 실측(2026-08-12) — 네 수단 중 강제력이 있는 것은 하나뿐이다: + · `tools` 목록 제외 ............ 하드(도구 자체가 없다) + · `allowedTools` ............... 포괄 자동 승인. 비대화에서 **필수**(없으면 전부 거부) + · `toolsSettings.*.allowedPaths` 비대화에서 자동 승인을 주지 못한다 → 반경 안쪽까지 거부 + · `toolsSettings.*.deniedPaths` **하드 차단. allowedTools보다 우선**(문서 신뢰 우선순위 1위, 실측 일치) + 즉 openRoots(홈+지정 폴더) 같은 **화이트리스트 의미를 직접 표현할 수 없다**(denyByDefault는 + shell 전용이고 write에서 무시됨, deny 글롭의 부정 패턴 `!`도 미지원 — 셋 다 실측 배제). + 그래서 이 러너의 집행은 **불변 경계 deny**로 한다: 지정으로도 열리지 않는 금지 구역 + (workroots.mjs "보안 경계(불변)")을 deny 목록으로 옮기고, caps.fs의 반경 차이는 UI가 정직 + 표기한다(러너별 집행 강도 차이는 이 레포가 이미 채택한 방식 — workroots.mjs 러너별 집행 절). + + 경계는 cwd에서 도출한다(permission-gate makeIsForbidden과 같은 계산 — 새 임포트 없이 정합): + cwd = 회사 워크스페이스 루트, dirname(cwd) = WS_ROOT(전 회사 데이터) */ +const GLOB_ALL = '/**'; + +/* 하드 차단 목록은 permission-gate.mjs와 **같은 값**이다 — 같은 파일을 SDK 러너는 deny하고 kiro는 + allow하면 러너별로 판정이 갈린다(그 파일 헤더의 "도구별 판정이 갈린다" 원칙과 같은 클래스). + 한쪽을 고치면 다른 쪽도 고쳐야 한다 — test/kiro-runner.test.mjs가 두 목록의 정합을 잠근다. */ +const HARD_HOME_PATHS = ['.argo', '.codex', '.claude', '.gemini', '.claude.json', '.mcp.json']; +/** 회사 금고 — 읽기·쓰기 모두 금지(자격·자가 승격의 본체). permission-gate WS_CONTROL_FILES와 동일. */ +const WS_CONTROL_FILES = ['capabilities.json', 'mcp.json', 'connections.json', 'company.json', 'routines.json', 'approvals.json', 'gw-cursor-slack.json']; +/** 크루 카드·대화 정본 — 결재 없는 자기 범위 확대·가짜 사장 발화 주입의 경로. 동일. */ +const WS_CONTROL_DIRS = ['agents', 'chats']; +/** 원장 — **쓰기만** 막고 읽기는 연다. permission-gate와 같은 근거(사장이 "이번 달 얼마 썼어?"라고 + 물었을 때 답할 수단을 없애는 것은 자가 승격 차단과 무관한 기능 후퇴다). */ +const WS_LEDGER_FILES = ['usage.jsonl', 'events.jsonl']; + +const canon = (p) => realpath(p).then((r) => r, () => null); + +/** 불변 경계 deny 글롭 — 반환 { read, write }(원장은 write만). + ① APP_ROOT — 실행 중인 Argo 코드. writable_roots="/"가 앱 본체를 열었던 2026-07-22 크리티컬의 계열. + ② 홈 직속 자격 — ~/.argo·~/.codex·~/.claude·~/.gemini·~/.claude.json·~/.mcp.json + (permission-gate HARD_HOME_PATHS와 같은 목록. "전권은 파일을 맡긴다는 뜻이지 자격을 넘긴다는 + 뜻이 아니다" — 그 파일 #187 원칙). + ③ WS_ROOT의 **형제 회사** — 교차 테넌트 차단. WS_ROOT 통째로 deny하면 자기 회사(cwd)까지 막히므로 + 형제만 열거한다(회사 수만큼 — readdir 1회). 열거 후 생성된 회사는 이 턴에 안 잡힌다(TOCTOU): + 회사 생성은 사장의 UI 행위라 턴 중 발생이 비현실적이고, 다음 턴엔 잡힌다 — 설계 문서에 명시. + ④ 직속 도트 항목 — `/.*`(.workroots.json 등) / `WS_ROOT/.*`(계정 시크릿·기기 마커). + 이 러너의 턴별 에이전트 설정(`/.kiro`)도 여기 포함된다 — 크루가 자기 권한 설정을 고쳐 + 다음 턴을 승격시키는 경로를 막는다. + ⑤ 회사 금고 — 도트가 아닌 제어 파일·디렉터리(위 두 상수). ④의 도트 글롭으로는 안 잡힌다. + + ⚠ **raw + canonical 두 형태를 모두 싣는다**(분리 검수 CRITICAL 2026-08-12 실증): kiro-cli는 + canonical 경로로 deny를 판정하는데 우리가 raw만 실으면, 심링크 경유 ARGO_ROOT(맥의 /tmp·/var, + 외장 볼륨·동기화 폴더 경유가 흔하다)에서 **경계 전체가 조용히 열린다** — 재현 시 형제 회사 + 파일과 WS_ROOT 직속 도트를 둘 다 읽었다. permission-gate가 하드 구역에 raw·canonical 두 형태를 + 담는 것과 같은 이유·같은 방식(그 파일 "정션·심링크 홈에서 한쪽 leg가 무동작하는 계열" 주석). + (export: 회귀 테스트용) */ +export async function kiroDeniedPaths(cwd, appRoot = APP_ROOT) { + const wsRoot = dirname(cwd); + const mine = basename(cwd); + const home = homeDir(); + // fail-closed(분리 검수 2라운드 MEDIUM): 홈을 모르면 홈 자격 경계(~/.argo·~/.codex·~/.claude· + // ~/.gemini 등)를 만들 수 없다. 조용히 빠지면 그 경계가 **경고도 없이 사라진다**(실측: + // env -u HOME -u USERPROFILE 에서 홈 항목 0건). deniedPaths가 이 러너 집행의 전부이므로, + // 경계를 못 만들면 턴을 돌리지 않는다 — launchd·Tauri 최소 env가 정확히 이 조건이다. + if (!home) { + throw new Error('Kiro 러너를 실행할 수 없습니다: HOME(또는 USERPROFILE)이 없어 보안 경계를 만들 수 없습니다. ' + + 'Cannot run the Kiro runner: HOME/USERPROFILE is unset, so the security boundary cannot be built.'); + } + // 형제는 **이름만** 뽑는다 — 경로는 아래에서 wsRoot의 두 형태에 각각 붙인다. + const siblingNames = await readdir(wsRoot, { withFileTypes: true }) + .then((es) => es.filter((e) => e.isDirectory() && e.name !== mine).map((e) => e.name)) + .catch(() => []); // 읽을 수 없으면 형제 열거를 포기한다 — 나머지 방어는 그대로 선다 + const forms = async (p) => { const c = await canon(p); return c && c !== p ? [p, c] : [p]; }; + const [appForms, homeForms, wsForms, cwdForms] = await Promise.all([ + forms(appRoot), forms(home), forms(wsRoot), forms(cwd), + ]); + + const read = new Set(); + // 파일 자체 + 하위 전부 — HARD_HOME_PATHS·금고 목록에 파일과 디렉터리가 섞여 있다. + const deny = (set, p) => { set.add(p); set.add(p + GLOB_ALL); }; + for (const a of appForms) deny(read, a); + for (const h of homeForms) for (const d of HARD_HOME_PATHS) deny(read, join(h, d)); + for (const w of wsForms) { + for (const s of siblingNames) deny(read, join(w, s)); + deny(read, join(w, '.*')); + } + for (const c of cwdForms) { + deny(read, join(c, '.*')); + for (const f of WS_CONTROL_FILES) deny(read, join(c, f)); + for (const d of WS_CONTROL_DIRS) deny(read, join(c, d)); + } + /* ⚠ 조상 사슬(WS_ROOT → … → 루트) deny로 "위에서 훑어 내려오기"를 봉인하려 했으나 **불가능하다** + (실측 2026-08-12): kiro-cli의 deny 판정은 정확 경로가 아니라 **디렉터리 포함**이다 — `/var/folders/…` + 한 줄을 넣으면 그 하위 파일 읽기가 전부 막힌다(`/`를 넣으면 전부 차단). 즉 WS_ROOT를 막으면 + 자기 회사 폴더(크루 책상)도 같이 죽는다. 그래서 `read` Directory 모드 재귀 열거로 금지 구역의 + **경로가 열거되는 것**(내용은 보호됨)은 설정으로 막을 수 없다 — docs/kiro-runner-design.md + 한계 절에 그 심각도로 명시했다. 근본 해법은 `kiro-cli acp`(도구 호출을 우리가 승인·거절). */ + const write = new Set(read); + for (const c of cwdForms) for (const f of WS_LEDGER_FILES) deny(write, join(c, f)); + return { read: [...read], write: [...write] }; +} + +/** 턴별 격리 에이전트 설정을 쓰고 에이전트 이름을 반환한다. + 위치는 `/.kiro/agents/.json` — kiro-cli는 워크스페이스 에이전트를 **cwd 기준으로만** + 찾는다(상위 탐색 없음, 실측). 전역 `~/.kiro/agents`를 쓰지 않는 이유: 사용자 자신의 kiro-cli + 에이전트 목록을 오염시키고 기기 전역이라 회사별 격리가 안 된다. + 이름에 턴 고유값을 넣어 **동시 턴 충돌·경합 삭제**를 막는다(같은 회사에서 두 크루가 동시에 답할 수 있다). + 호출부는 반드시 finally에서 removeKiroTurnAgent로 지운다 — 회사 금고에 잔재를 남기지 않는다. */ +export async function writeKiroTurnAgent(cwd, { caps = null, appRoot = APP_ROOT, name } = {}) { + const { tools, allowedTools } = kiroTools(caps); + const denied = await kiroDeniedPaths(cwd, appRoot); + const cfg = { + name, + description: 'Argo turn-scoped runner agent (generated per turn — do not edit)', + tools, + allowedTools, + toolsSettings: { + read: { deniedPaths: denied.read }, + write: { deniedPaths: denied.write }, // 원장(usage/events)은 쓰기만 추가로 막힌다 + // read·write 둘뿐인 이유는 위 BASE_TOOLS 주석에 있다: deniedPaths가 이 두 도구에만 서고, + // grep·glob·shell은 같은 파일을 그냥 연다(실측). 경계가 없는 도구를 주지 않는 것이 + // 이 러너의 fail-closed다 — 설정으로 막을 수 없으니 도구 자체를 안 준다. + }, + // 전역 설정 격리 — 사용자의 MCP 서버가 매 턴 로드되며 경고를 뱉고(실측) 크루에게 의도 밖 + // 도구를 준다. 빈 목록 + 레거시 json 미상속으로 닫는다. + mcpServers: {}, + useLegacyMcpJson: false, + }; + const dir = join(cwd, '.kiro', 'agents'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, `${name}.json`), `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 }); + return name; +} + +/** 턴별 에이전트 설정 제거 — 실패는 무시(잔재 1개가 다음 턴을 막지 않는다). + 빈 디렉터리까지 걷어낸다: rmdir은 비어 있지 않으면 실패하므로 동시 턴의 설정이나 사용자가 만든 + `.kiro` 내용물을 지울 위험이 없다(분리 검수 LOW). */ +export async function removeKiroTurnAgent(cwd, name) { + await rm(join(cwd, '.kiro', 'agents', `${name}.json`), { force: true }).catch(() => {}); + await rmdir(join(cwd, '.kiro', 'agents')).catch(() => {}); + await rmdir(join(cwd, '.kiro')).catch(() => {}); +} + +/* ─── 파일 반경(openRoots)을 이 러너가 쓰지 않는 이유 — 정직 표기 ─── + codex(writable_roots)·gemini(includeDirectories)·antigravity(--add-dir)는 openRoots를 인자로 + 받아 **반경을 넓힌다**. kiro-cli는 기본이 무제한이라(실측: cwd 밖 읽기·쓰기가 그냥 된다) 넓힐 + 것이 없고, 좁히는 유일한 수단인 deniedPaths는 화이트리스트를 표현하지 못한다(위 실측 표). + `toolsSettings.read.allowedPaths`에 openRoots를 넣어 두는 선택지는 **일부러 버렸다** — 비대화 + 모드에서 효력이 0인데(실측) 설정 파일에는 반경이 적혀 있어, 다음 사람이 "반경이 걸려 있다"고 + 오독할 죽은 설정이 된다. 러너별 집행 강도 차이는 UI가 정직 표기한다(workroots.mjs 러너별 집행 절). + 후속: `kiro-cli acp`로 옮기면 도구 호출을 우리가 승인·거절하므로 SDK 러너와 같은 강도가 된다. */ + diff --git a/src/trial.mjs b/src/trial.mjs index 426362e4..7d38bfef 100644 --- a/src/trial.mjs +++ b/src/trial.mjs @@ -35,7 +35,7 @@ export function runTrialTurn(wsId, slug) { // 서버측이라 UI 언어를 모른다 — ko/en 병기(다국어 규칙: 하드코딩 단일언어 금지) const reply = connected ? `첫 시운전이 실패했어요 — AI 연결은 되어 있으니 지시를 한 번 더 보내주시면 이어서 시작할게요.\n(원인: ${m.slice(0, 200)})\n\nThe trial run failed — your AI runner is connected, so just send an instruction and I'll pick it up.\n(Reason: ${m.slice(0, 200)})` - : 'AI 연결이 아직 안 되어 있어요. 설정 → AI 연결에서 Claude·Codex·Gemini·Antigravity·GLM·Kimi·OpenRouter·Grok 중 하나를 연결하면 바로 일을 시작할게요.\n\nAI isn\'t connected yet. Connect any runner (Claude, Codex, Gemini, Antigravity, GLM, Kimi, OpenRouter, or Grok) in Settings → AI connections and I\'ll get started.'; + : 'AI 연결이 아직 안 되어 있어요. 설정 → AI 연결에서 Claude·Codex·Gemini·Antigravity·Kiro·GLM·Kimi·OpenRouter·Grok 중 하나를 연결하면 바로 일을 시작할게요.\n\nAI isn\'t connected yet. Connect any runner (Claude, Codex, Gemini, Antigravity, Kiro, GLM, Kimi, OpenRouter, or Grok) in Settings → AI connections and I\'ll get started.'; await appendTurn(wsId, slug, { userMsg: '(영입 시운전) 첫 인사와 샘플 산출물을 보여주세요.', reply, handover: null, sessionId: null, diff --git a/src/workroots.mjs b/src/workroots.mjs index d844e1dd..78f1da8d 100644 --- a/src/workroots.mjs +++ b/src/workroots.mjs @@ -24,6 +24,15 @@ // 도구가 거부한다. 인자를 안 넘기면 지정 폴더는커녕 홈조차 막혀 크루가 "허용된 작업 디렉토리 // 외부"라며 거절한다 — 라이브 재현 2026-07-30.) // - antigravity: `--add-dir`에 추가(agy 플래그 — 같은 이유·같은 계산). +// - kiro: **반경이 적용되지 않는다**(정직 표기 대상). kiro-cli는 기본이 무제한이고(cwd 밖 읽기·쓰기가 +// 그냥 된다), 좁히는 유일한 수단인 `toolsSettings.*.deniedPaths`로는 화이트리스트를 표현할 수 없다 +// (실측 2026-08-12: allowedPaths는 비대화에서 자동 승인을 주지 못해 반경 안쪽까지 거부되고, +// denyByDefault는 shell 전용이라 write에서 무시되며, deny 글롭의 부정 패턴 `!`도 미지원). +// 그래서 이 러너의 집행은 **불변 금지 구역 deny**뿐이다 — APP_ROOT·~/.argo·다른 회사·직속 도트 +// (src/runners/kiro.mjs kiroDeniedPaths). 지정 폴더는 열거나 좁히는 효과가 없다. +// UI 문구(i18n settings.workroots.runnerNote)가 이 차이를 ko·en 양쪽에 명시하고, +// test/kiro-runner.test.mjs가 그 표기를 잠근다. 근본 해법은 `kiro-cli acp`(도구 호출을 우리가 +// 승인·거절 — SDK 러너와 같은 강도)로 옮기는 것: docs/kiro-runner-design.md 후속 항목. import { stat, realpath } from 'node:fs/promises'; import { isAbsolute, join, resolve, dirname } from 'node:path'; import { homedir } from 'node:os'; diff --git a/test/cli-turn-timeout.test.mjs b/test/cli-turn-timeout.test.mjs index a738443e..9342fe0e 100644 --- a/test/cli-turn-timeout.test.mjs +++ b/test/cli-turn-timeout.test.mjs @@ -66,9 +66,9 @@ test('배선: chat.mjs — 잡 6시간·대화 5분 상한 + kind가 두 externa assert.equal((src.match(/kind: source === 'job' \? 'job' : 'chat'/g) ?? []).length, 2, 'kind 인지형 안내 배선'); }); -test('배선: runners.mjs — 세 CLI 경로 전부 cliTurnFailure 경유 + codex는 exec/read 두 단계 구분', async () => { +test('배선: runners.mjs — 네 CLI 경로 전부 cliTurnFailure 경유 + codex는 exec/read 두 단계 구분', async () => { const src = await readFile(new URL('../src/runners.mjs', import.meta.url), 'utf8'); - for (const r of ['codex', 'gemini', 'antigravity']) { + for (const r of ['codex', 'gemini', 'antigravity', 'kiro']) { assert.match(src, new RegExp(`cliTurnFailure\\(e, '${r}'`), `${r} 경로 번역`); } assert.match(src, /cliTurnFailure\(e, 'codex', .*\{ stage: 'exec', kind \}/, 'codex exec 단계'); diff --git a/test/kiro-runner.test.mjs b/test/kiro-runner.test.mjs new file mode 100644 index 00000000..817c72f8 --- /dev/null +++ b/test/kiro-runner.test.mjs @@ -0,0 +1,436 @@ +// Kiro 러너(BYOA 3호) — 카탈로그·인증 방식·디스패치·답변 추출·경계 집행 회귀 테스트. +// +// 배경: kiro-cli(AWS Kiro CLI)는 IAM Identity Center / Builder ID 로그인을 쓰고 붙여넣을 API 키 +// 표면이 없다 — codex·antigravity와 같은 CLI 래핑 + host 옵트인 전용. antigravity와 갈리는 두 점을 +// 이 파일이 잠근다: ① 자격을 `whoami`로 **실측**하므로 낙관 authed(authUnknown)가 아니다 +// ② 파일 반경을 강제할 수단이 없어 집행이 **불변 경계 deny**뿐이다(정직 표기 대상). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, writeFile, access, symlink, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + RUNNERS, RUNNER_AUTH, isCliRunner, apiError, + kiroScrub, kiroTools, kiroEffortArgs, kiroDeniedPaths, writeKiroTurnAgent, removeKiroTurnAgent, +} from '../src/runners.mjs'; +import { PICK_ORDER } from '../app/runner-usable.mjs'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (p) => readFileSync(join(root, p), 'utf8'); +const exists = (p) => access(p).then(() => true, () => false); + +test('카탈로그 — kiro는 kind cli, 첫 모델은 계정 무관 기본값(auto)', () => { + const r = RUNNERS.kiro; + assert.ok(r, 'RUNNERS.kiro가 없다'); + assert.equal(r.kind, 'cli'); + assert.equal(r.name, 'Kiro'); + assert.ok(r.models.length >= 5, '--list-models 실측 목록이 비었다'); + // models[0]은 러너 전환·모델 미지정의 기본값 — 권한·잔액에 의존하는 모델을 앞에 두면 신규 + // 계정의 영입·기억정리가 이유 없이 죽는다(gemini gated·openrouter 402 선례와 같은 원칙). + assert.equal(r.models[0].id, 'auto', 'kiro-cli 자신의 기본값(auto)이 첫 항목이어야 한다'); + assert.ok(!r.models.some((m) => m.gated), '게이트 모델은 등재하지 않았다(실턴 통과분만)'); +}); + +test('인증 방식 — 호스트 로그인 옵트인 전용(붙여넣을 키 표면이 없다)', () => { + const a = RUNNER_AUTH.kiro; + assert.deepEqual(a.methods, ['oauth']); + assert.equal(a.oauthPasteable, false, 'CLI 로그인 자격은 붙여넣을 수 없다'); + assert.ok(a.hostUsable, '호스트 옵트인이 유일한 연결 경로다'); + assert.ok(!a.webConnect, '웹 브리지는 아직 없다 — 생기면 webauth 배관과 함께 검토'); + // connect(로그인 대행)를 두지 않은 이유: `kiro-cli login`이 라이선스·IdP를 되묻는 대화형일 수 + // 있어(미검증) detached spawn이 조용히 멈춘다. 붙이려면 statusArgs의 ok 정규식이 반드시 + // 줄머리 앵커여야 한다 — 아래 테스트가 그 함정을 잠근다. + assert.ok(!a.connect, 'connect를 붙였다면 whoami 오판정 테스트를 함께 갱신해야 한다'); +}); + +test('자격 판정 함정 — /Logged in/i는 "Not logged in"에도 매칭된다(줄머리 앵커 필수)', () => { + // 실측 2026-08-12: 미로그인 whoami stdout = "Not logged in". codex가 쓰는 느슨한 정규식을 + // 그대로 재사용하면 **미로그인을 연결됨으로 뒤집는다** — 한 글자(^) 차이가 보안 표면이다. + assert.match('Not logged in', /Logged in/i, '느슨판은 미로그인도 통과한다(이게 함정의 실체)'); + assert.doesNotMatch('Not logged in', /^Logged in/im, '앵커판은 미로그인을 걸러낸다'); + assert.match('Logged in with IAM Identity Center (https://example.awsapps.com/start/)', /^Logged in/im); + // 배선 — 감지 코드가 앵커판을 쓰는지 소스로 잠근다(순수 함수가 맞아도 호출부가 느슨하면 무의미). + assert.match(read('src/runners/exec.mjs'), /\/\^Logged in\/im\.test/, 'detectRunners가 앵커판을 쓰지 않는다'); +}); + +test('디스패치 — isCliRunner가 카탈로그 kind 기준으로 kiro를 CLI로 본다', () => { + assert.equal(isCliRunner('kiro'), true); + assert.equal(isCliRunner('claude'), false); +}); + +test('자동 선택 순서 — PICK_ORDER는 RUNNER_AUTH 정의 순과 일치한다', () => { + assert.deepEqual(PICK_ORDER, Object.keys(RUNNER_AUTH)); +}); + +test('연결 UI — RUNNER_ORDER·RUNNER_NAMES에 kiro가 있다', () => { + const src = read('app/runner-connect.jsx'); + assert.match(src.match(/RUNNER_ORDER = \[([^\]]*)\]/)[1], /'kiro'/); + assert.match(src, /kiro: 'Kiro'/); +}); + +test('감지 — kiro는 낙관 authed가 아니다(authUnknown 미사용)', () => { + // antigravity는 키링이라 authed=installed(낙관)이고 UI가 '확인 불가'를 그린다. kiro는 whoami로 + // 실측하므로 그 완충이 필요 없다 — 낙관값이 아니라는 사실이 RUNNER_AUTH 배치(맨 끝 아님)의 근거다. + const src = read('src/runners/exec.mjs'); + const line = src.split('\n').find((l) => /^\s*kiro: \{ installed:/.test(l)); + assert.ok(line, 'detectRunners에 kiro 항목이 없다'); + assert.doesNotMatch(line, /authUnknown/, 'kiro에 authUnknown이 붙었다 — 실측 authed와 모순'); + assert.match(line, /&& kiroAuth/, 'authed가 whoami 결과를 요구하지 않는다(설치만으로 연결됨이 된다)'); +}); + +test('에러 매핑 — 미로그인 OAuth 타임아웃이 로그인 안내로 번역된다(kiro 한정)', () => { + // 격리 HOME 실측 2026-08-12: 비대화에서도 브라우저를 열려 시도하고 아무도 승인하지 않아 + // stderr `error: OAuth error: Auth portal timed out`(exit 1). stdout엔 스피너만. + const e = Object.assign(new Error('cmd failed'), { stdout: 'Opening browser...', stderr: 'error: OAuth error: Auth portal timed out', code: 1 }); + const mapped = apiError(e, 'kiro'); + assert.match(mapped.message, /kiro-cli login/, '로그인 처방이 없다'); + // AUTH_ERR_RE(chat.mjs) 계약 — 이 표현이 자가치유(남은 가용 러너 순차 폴백)를 발화시킨다. + assert.match(mapped.message, /not logged in/i, 'AUTH_ERR_RE가 잡을 표현이 없다 — 자가치유 소실'); +}); + +test('에러 매핑 — 같은 문구라도 다른 러너(stdout 오염)는 오분류하지 않는다', () => { + // 크루가 셸로 실행한 명령 출력에 같은 문구가 섞인 codex 실패 — 벤더 원인이 보존돼야 한다 + // (antigravity M1과 같은 클래스의 방어). + const e = Object.assign(new Error('x'), { + stdout: '$ some-tool\nerror: OAuth error: Auth portal timed out\n', stderr: '{"message":"invalid api key"}', code: 1, + }); + const mapped = apiError(e, 'codex'); + assert.doesNotMatch(mapped.message, /kiro-cli login/, 'codex 실패가 Kiro 문구로 오분류됐다'); + assert.match(mapped.message, /invalid api key/i, '벤더 원인이 지워졌다 — 자가치유 소실'); +}); + +test('답변 추출 — 도구 추적을 걷고 마지막 어시스턴트 블록만 남긴다', () => { + // kiro-cli는 어시스턴트 메시지 **첫 줄에만** '> '를 붙인다(도구 추적 줄은 무접두사) — 실측 형상. + const out = [ + '\u001B[m> \u001B[0mI\u2019ll create the file.', + 'I\u2019ll create the following file: ./note.txt (using tool: write)', + '+ 1: HELLO', + 'Creating: ./note.txt', + ' - Completed in 0.0s', + '', + '> FILE: note.txt', + 'CONTENT: HELLO', + 'STATUS: DONE', + ].join('\n'); + assert.equal(kiroScrub(out), 'FILE: note.txt\nCONTENT: HELLO\nSTATUS: DONE'); +}); + +test('답변 추출 — 마크다운 인용은 접두사와 충돌하지 않는다(렌더러가 │ 로 그린다)', () => { + // 실측: 모델이 '> quoted'를 내면 렌더 결과는 '│ quoted'다. 그래서 인용이 있는 답변이 + // 인용 줄에서 절단되지 않는다 — 이 계약이 깨지면(렌더러 변경) 답변 앞부분이 조용히 사라진다. + assert.equal(kiroScrub('> Intro line.\n\u2502 quoted one\n\u2502 quoted two\nEnd line.'), + 'Intro line.\n\u2502 quoted one\n\u2502 quoted two\nEnd line.'); +}); + +test('답변 추출 — 접두사가 없으면 전문을 반환한다(조용한 빈 답변 금지)', () => { + // 미래 렌더러 변경·예상 외 형식에서 통삭제로 정상 응답을 지우는 방향(gemini 스크럽 선례)을 막는다. + assert.equal(kiroScrub('plain answer\nsecond line'), 'plain answer\nsecond line'); + assert.equal(kiroScrub(''), ''); + assert.equal(kiroScrub(null), ''); +}); + +test('추론 강도 — 아는 값만 넘긴다(미지값은 인자 오류로 턴을 죽인다)', () => { + assert.deepEqual(kiroEffortArgs('high'), ['--effort', 'high']); + assert.deepEqual(kiroEffortArgs('max'), ['--effort', 'max']); + assert.deepEqual(kiroEffortArgs(''), []); + assert.deepEqual(kiroEffortArgs('turbo'), []); + assert.deepEqual(kiroEffortArgs(undefined), []); +}); + +test('도구 목록 — 경계가 증명된 도구만 준다(grep·glob·shell 배제)', () => { + // 분리 검수 2라운드 CRITICAL 실측: deniedPaths는 read·write에만 선다. 같은 턴·같은 설정에서 + // grep → 금고 connections.json·형제 회사·WS_ROOT 도트의 매치 줄 내용을 그대로 반환 + // glob → ~/.codex 7,766개·~/.claude 527개 파일 열거 + // shell → cat <금고>·cat ../<타사>·echo > usage.jsonl(원장 개조) 전부 성공 + // 반면 read로 같은 파일을 열면 정상 DENY(대조군). 즉 설정으로 막을 수 없는 도구는 **주지 않는 것**이 + // 유일한 fail-closed다. 셸 부재는 선례가 있다 — gemini도 비대화에서 셸이 실행되지 않는다. + for (const caps of [null, { fs: true }, { fs: true, shell: true }, { fs: true, shell: true, browser: true, bypass: true }]) { + const { tools } = kiroTools(caps); + for (const banned of ['grep', 'glob', 'shell']) { + assert.ok(!tools.includes(banned), `caps=${JSON.stringify(caps)}에서 ${banned}가 들어갔다 — 경계 우회 도구`); + } + } + assert.deepEqual(kiroTools(null).tools, ['read', 'write']); + // 브라우저는 사장이 켠 경우만. 읽을 수 있는 데이터가 이미 deniedPaths로 제한되므로 반출 범위도 그만큼. + assert.ok(kiroTools({ browser: true }).tools.includes('web_fetch')); + assert.ok(!kiroTools({ fs: true }).tools.includes('web_fetch'), 'caps 미지정 브라우저가 열렸다 — fail-open'); + // 비대화에서는 포괄 신뢰가 없으면 도구 호출이 전부 거부된다(실측) — allowedTools는 tools와 같아야 한다. + const t = kiroTools({ browser: true }); + assert.deepEqual(t.allowedTools, t.tools, 'allowedTools가 tools와 다르면 비대화 턴에서 도구가 거부된다'); +}); + +test('경계 생성 fail-closed — HOME이 없으면 턴을 돌리지 않는다', async () => { + // 분리 검수 2라운드 MEDIUM 실측: env -u HOME -u USERPROFILE 에서 홈 자격 경계(~/.argo·~/.codex· + // ~/.claude·~/.gemini)가 **경고도 없이 0건**이 됐다. deniedPaths가 이 러너 집행의 전부라 + // 조용한 소멸은 곧 경계 소멸이다. launchd·Tauri 최소 env가 정확히 이 조건이다. + const home = process.env.HOME; + const up = process.env.USERPROFILE; + delete process.env.HOME; + delete process.env.USERPROFILE; + try { + await assert.rejects(() => kiroDeniedPaths('/tmp/ws/acme'), /HOME/i, 'HOME 부재가 fail-open으로 통과했다'); + } finally { + if (home !== undefined) process.env.HOME = home; + if (up !== undefined) process.env.USERPROFILE = up; + } +}); + +test('불변 경계 — 앱 루트·홈 자격·형제 회사·직속 도트·회사 금고가 deniedPaths에 실린다', async () => { + // CLI 러너는 프로세스 단위라 도구 게이트(permission-gate)를 지나지 않는다. kiro는 반경 화이트리스트를 + // 표현할 수 없어(실측) **불변 경계 deny**가 집행의 전부다 — 그래서 이 목록이 곧 보안 경계다. + const wsRoot = await mkdtemp(join(tmpdir(), 'argo-kiro-ws-')); + const mine = join(wsRoot, 'acme'); + const other = join(wsRoot, 'other-co'); + await mkdir(mine, { recursive: true }); + await mkdir(other, { recursive: true }); + const appRoot = join(wsRoot, 'fake-app-root'); + const { read, write } = await kiroDeniedPaths(mine, appRoot); + + assert.ok(read.includes(`${appRoot}/**`), '실행 중인 Argo 코드 루트가 열려 있다'); + assert.ok(read.includes(`${other}/**`), '다른 회사 데이터가 열려 있다(교차 테넌트)'); + assert.ok(read.includes(join(mine, '.*')), '회사 금고 직속 도트(자가 승격 경로)가 열려 있다'); + assert.ok(read.includes(join(wsRoot, '.*')), 'WS_ROOT 직속 도트(계정 시크릿)가 열려 있다'); + // 자기 회사 폴더 자체는 막지 않는다 — 크루의 책상이다(WS_ROOT를 통째로 deny하면 여기가 죽는다). + assert.ok(!read.includes(`${mine}/**`), '자기 회사 폴더가 deny에 들어갔다 — 크루가 일할 곳이 없다'); +}); + +test('불변 경계 — permission-gate의 하드 차단 목록과 정합한다(도구별 판정 갈림 방지)', async () => { + // 같은 파일을 SDK 러너는 deny하고 kiro는 allow하면 러너에 따라 자격 유출 여부가 갈린다. + // permission-gate.mjs의 상수를 소스에서 읽어 대조한다 — 한쪽만 늘어나면 이 테스트가 잡는다. + const gate = read('src/permission-gate.mjs'); + const hardBlock = (gate.match(/const HARD_HOME_PATHS = \[([\s\S]*?)^\];/m) ?? [])[1] ?? ''; + const fileBlock = (gate.match(/const HARD_HOME_FILE_PREFIXES = \[([^\]]*)\]/) ?? [])[1] ?? ''; + const hardHome = [...`${hardBlock}${fileBlock}`.matchAll(/'(\.[^']+)'/g)].map((m) => m[1]); + const ctlFiles = (gate.match(/const WS_CONTROL_FILES = new Set\(\[([\s\S]*?)\]\)/) ?? [])[1] ?? ''; + const ctlDirs = (gate.match(/const WS_CONTROL_DIRS = new Set\(\[([^\]]*)\]\)/) ?? [])[1] ?? ''; + const ledgers = (gate.match(/const WS_LEDGER_FILES = new Set\(\[([\s\S]*?)\]\)/) ?? [])[1] ?? ''; + + const wsRoot = await mkdtemp(join(tmpdir(), 'argo-kiro-gate-')); + const cwd = join(wsRoot, 'acme'); + await mkdir(cwd, { recursive: true }); + const { read: rd, write: wr } = await kiroDeniedPaths(cwd, join(wsRoot, 'app')); + + assert.ok(hardHome.length >= 4, `permission-gate 하드 홈 목록 파싱 실패(${hardHome.length}건)`); + for (const d of hardHome) { + assert.ok(rd.some((p) => p.endsWith(d)), `홈 자격 ${d}가 kiro deny에 없다 — 러너별 판정 갈림`); + } + for (const m of ctlFiles.matchAll(/'([^']+)'/g)) { + assert.ok(rd.some((p) => p.endsWith(m[1])), `회사 금고 ${m[1]}가 kiro deny에 없다`); + } + for (const m of ctlDirs.matchAll(/'([^']+)'/g)) { + assert.ok(rd.some((p) => p.endsWith(m[1])), `금고 디렉터리 ${m[1]}가 kiro deny에 없다`); + } + // 원장은 **쓰기만** 막는다 — permission-gate와 같은 근거(사용액 조회 기능을 죽이지 않는다). + for (const m of ledgers.matchAll(/'([^']+)'/g)) { + assert.ok(wr.some((p) => p.endsWith(m[1])), `원장 ${m[1]}가 write deny에 없다`); + assert.ok(!rd.some((p) => p.endsWith(m[1])), `원장 ${m[1]}가 read까지 막혔다 — 사용액 조회 기능 후퇴`); + } +}); + +test('불변 경계 — raw·canonical 두 형태를 모두 싣는다(심링크 ARGO_ROOT 우회 차단)', async () => { + // 분리 검수 CRITICAL 실증(2026-08-12): kiro-cli는 canonical 경로로 deny를 판정한다. raw만 실으면 + // 심링크 경유 ARGO_ROOT(맥의 /tmp·/var, 외장 볼륨·동기화 폴더가 흔하다)에서 경계가 통째로 열렸다 — + // 형제 회사 파일과 WS_ROOT 직속 도트를 둘 다 읽는 것이 재현됐다. 공격이 아니라 **설정만으로** 열린다. + const real = await mkdtemp(join(tmpdir(), 'argo-kiro-real-')); + await mkdir(join(real, 'acme'), { recursive: true }); + await mkdir(join(real, 'other-co'), { recursive: true }); + const link = `${real}-link`; + await symlink(real, link); + const { read: rd } = await kiroDeniedPaths(join(link, 'acme'), join(real, 'app')); + // 맥 tmpdir(/var/folders)는 그 자체가 /private/var 심링크다 — 기대값도 realpath로 계산한다. + const canonReal = await realpath(real); + + assert.ok(rd.some((p) => p.startsWith(link) && p.includes('other-co')), '심링크 형태 형제 deny 누락'); + assert.ok(rd.some((p) => p.startsWith(canonReal) && !p.startsWith(link) && p.includes('other-co')), + 'canonical 형태 형제 deny 누락 — 심링크 ARGO_ROOT에서 경계가 조용히 열린다'); + assert.ok(rd.some((p) => p === join(link, '.*')) && rd.some((p) => p === join(canonReal, '.*')), + 'WS_ROOT 직속 도트가 두 형태로 실리지 않았다'); +}); + +test('불변 경계 — WS_ROOT를 못 읽어도 앱 루트·도트 방어는 남는다(fail-safe 방향)', async () => { + const gone = join(tmpdir(), 'argo-kiro-nonexistent', 'acme'); + const { read: rd } = await kiroDeniedPaths(gone, '/tmp/app'); + assert.ok(rd.includes('/tmp/app/**'), '형제 열거 실패가 다른 방어까지 지웠다'); + assert.ok(rd.includes(join(gone, '.*'))); +}); + +test('턴별 에이전트 설정 — 경계·격리가 실제로 파일에 실리고 턴 뒤 지워진다', async () => { + const wsRoot = await mkdtemp(join(tmpdir(), 'argo-kiro-turn-')); + const cwd = join(wsRoot, 'acme'); + await mkdir(cwd, { recursive: true }); + const name = 'argo-test01'; + await writeKiroTurnAgent(cwd, { caps: { fs: true, shell: true, browser: false }, name }); + const file = join(cwd, '.kiro', 'agents', `${name}.json`); + const cfg = JSON.parse(await readFile(file, 'utf8')); + + assert.equal(cfg.name, name); + // caps.shell이 켜져 있어도 셸을 주지 않는다 — 경계가 서지 않는 도구는 배제(위 테스트의 계약) + assert.ok(!cfg.tools.includes('shell'), '셸이 실렸다 — deniedPaths가 셸에 적용되지 않아 경계가 무의미해진다'); + assert.ok(!cfg.tools.includes('web_fetch'), '브라우저 능력이 꺼졌는데 web_fetch가 실렸다'); + assert.deepEqual(cfg.allowedTools, cfg.tools); + assert.ok(cfg.toolsSettings.read.deniedPaths.length, 'read 경계가 비었다'); + assert.ok(cfg.toolsSettings.write.deniedPaths.length, 'write 경계가 비었다'); + // 원장은 쓰기만 추가로 막힌다 — write 목록이 read보다 커야 한다(permission-gate와 같은 근거). + assert.ok(cfg.toolsSettings.write.deniedPaths.length > cfg.toolsSettings.read.deniedPaths.length, + 'write 경계가 read와 같다 — 원장 쓰기 차단이 빠졌다'); + // 전역 설정 격리 — 사용자 MCP 서버가 매 턴 로드되며 크루에게 의도 밖 도구를 준다(실측: 경고 발생). + assert.deepEqual(cfg.mcpServers, {}, '전역 MCP 격리가 풀렸다'); + assert.equal(cfg.useLegacyMcpJson, false, '레거시 MCP json 상속이 열렸다'); + + await removeKiroTurnAgent(cwd, name); + assert.equal(await exists(file), false, '턴 잔재가 회사 금고에 남았다'); +}); + +test('배선 — externalExec가 턴별 설정을 쓰고 finally에서 지운다', () => { + // 순수 함수가 맞아도 호출부가 빠지면 경계는 안 실린다(러너 중립성 HIGH-1의 교훈). + // 고유 이름도 함께 잠근다: 고정 이름이면 같은 회사 동시 턴에서 한쪽 finally가 다른 쪽 설정을 지운다. + const src = read('src/runners.mjs'); + assert.match(src, /await writeKiroTurnAgent\(cwd, \{ caps, name \}\)/, 'kiro 턴별 설정 배선이 없다'); + assert.match(src, /randomUUID\(\)\.slice\(0, 8\)/, '턴 고유 이름이 아니다 — 동시 턴 경합'); + assert.match(src, /finally \{\s*\n\s*await removeKiroTurnAgent\(cwd, name\)/, 'finally 정리 배선이 없다'); + assert.match(src, /return kiroScrub\(stdout\)/, '최종 답변 추출이 배선되지 않았다'); +}); + +test('러너 열거 안내 문구에 Kiro가 빠지지 않았다(전수 수색 규칙)', () => { + for (const f of ['src/chat.mjs', 'src/oneshot.mjs', 'src/trial.mjs', 'src/persona.mjs']) { + const src = read(f); + for (const m of src.matchAll(/Claude[·, ]+Codex[·, ]+Gemini[^)\n']*/g)) { + assert.ok(m[0].includes('Kiro'), `${f}의 러너 열거에 Kiro 누락: "${m[0].slice(0, 80)}"`); + } + } +}); + +test('정직 표기 — 폴더 안내가 kiro의 반경 미강제를 ko·en 양쪽에 명시한다', () => { + // 이 러너만 지정 폴더가 반경으로 적용되지 않는다(실측: allowedPaths는 비대화에서 효력 0, + // denyByDefault는 write 미지원, deny 글롭 부정 패턴 미지원). 화면이 이걸 말하지 않으면 + // 사장은 폴더를 좁게 지정하고 안전하다고 믿는다 — 거짓 유효 표기 금지 원칙(H1c 계열). + const src = read('app/i18n.jsx'); + const line = src.split('\n').find((l) => l.includes("'settings.workroots.runnerNote'")); + assert.ok(line, 'workroots 러너 안내 문자열이 없다'); + assert.match(line, /Kiro 크루는 예외/, '한국어 안내에 kiro 한계 표기가 없다'); + assert.match(line, /Kiro is the exception/, '영어 안내에 kiro 한계 표기가 없다'); +}); + +test('격리 — 턴별 설정이 회사 금고 직속 도트라 크루가 스스로 못 고친다(자가 승격 차단)', async () => { + // 크루가 `/.kiro/agents/*.json`을 고쳐 다음 턴의 deniedPaths를 지우는 승격 경로를 막는다. + const wsRoot = await mkdtemp(join(tmpdir(), 'argo-kiro-esc-')); + const cwd = join(wsRoot, 'acme'); + await mkdir(cwd, { recursive: true }); + await writeFile(join(wsRoot, 'sentinel'), 'x'); + const { read: denied } = await kiroDeniedPaths(cwd, join(wsRoot, 'app')); + const dotGlobs = denied.filter((p) => p.startsWith(join(cwd, '.*'))); + assert.ok(dotGlobs.length >= 2, '직속 도트 항목이 파일·하위 both로 막혀 있지 않다(.kiro/agents 하위가 열린다)'); +}); + +test('답변 추출 — 거부 턴의 접합부 표지를 놓치지 않는다(추적 줄이 답변에 섞이지 않게)', () => { + // 분리 검수 4라운드 HIGH 실증: 거부된 도구 호출 뒤 kiro-cli는 다음 메시지를 추적 줄 **끝에 개행 없이** + // 붙인다 — `Reading file: … (using tool: read)> 차단되었습니다…`. 줄머리 판정만 쓰면 이 경계를 놓쳐 + // last가 앞쪽 '> '를 가리키고 중간 추적 줄이 답변에 전부 실린다(거부 턴 3/3 재현). + assert.equal(kiroScrub('Reading file: /x (using tool: read)> 차단되었습니다.\n추가 설명.'), '차단되었습니다.\n추가 설명.'); + assert.equal(kiroScrub('I will create it.(using tool: write)> ISO_DONE'), 'ISO_DONE'); + // 거부 근거가 메시지보다 **앞**에 오는 변형도 있다(실측) — 그때도 답변만 남아야 한다. + assert.equal(kiroScrub('Reading file: /x (using tool: read)Command fs_read is rejected because …:\n - /x/**\n> 차단됨.'), '차단됨.'); + // 산문의 괄호 뒤 인용을 표지로 오인하면 답변이 절단된다 — 접합부는 `(using tool: X)`로 한정한다. + assert.equal(kiroScrub('> 답변(참고)> 이건 인용이 아니다'), '답변(참고)> 이건 인용이 아니다'); +}); + +test('답변 추출 폴백 — 도구 진단(거부 파일 diff)이 답변으로 새지 않는다', () => { + // 분리 검수 3라운드 MEDIUM 실증: kiro-cli fs_write는 **deny 검사 전에** 기존 내용을 diff로 렌더한다. + // 정상 턴은 마지막 '> ' 블록만 취해 버려지지만, 접두사 없는 예상 외 형식의 폴백이 stdout 전문을 + // 답변으로 돌리면 그 진단이 대화 정본(chats/)에 저장되고 다음 턴 맥락으로 재주입된다. + const raw = [ + 'Reading file: /Users/u/.codex/auth.json, all lines (using tool: read)', + '+ 1: {"accessToken":"eyJhbGciOi.SECRET_TOKEN.xyz"}', + ' - Completed in 0.0s', + 'Command fs_read is rejected because it matches one or more rules on the denied list:', + ' - /Users/u/.codex', + '실제 답변 문장.', + ].join('\n'); + const out = kiroScrub(raw); + assert.doesNotMatch(out, /SECRET_TOKEN/, '거부된 파일 내용이 답변으로 샜다'); + assert.match(out, /실제 답변 문장/, '정상 답변까지 지웠다(통삭제 방향 금지)'); + // ⚠ 트레이드오프(의도된 동작): 추적 줄 제거를 **항상** 적용하므로, 답변 본문이 kiro식 번호 diff + // (`+` + 공백 2칸 이상 + 숫자 + `:`)를 그대로 담고 있으면 그 줄도 지워진다. 번호 diff는 + // fs_write가 **거부 전에** 기존 파일 내용을 렌더하는 자리라 유출 위험이 가장 큰 규칙이고, + // 그걸 살리는 대가로 이 좁은 산문 손실을 받아들였다(설계 문서 한계 절에 명시). + assert.equal(kiroScrub('> 설명.\n+ 1: 답변 안의 diff 예시\n계속'), '설명.\n계속'); + // 일반 목록·비교 연산 같은 흔한 산문은 손대지 않는다(공백 1칸은 규칙 밖). + assert.equal(kiroScrub('> 할 일\n- 1: 첫째\n+ 1: 둘째'), '할 일\n- 1: 첫째\n+ 1: 둘째'); + // 6라운드 CRITICAL — 거부 규칙 목록(` - /path`)과 **정상 중첩 마크다운 불릿**이 형태가 같다. + // 단독 줄 규칙으로 두면 "폴더 아래 파일 보여줘" 같은 상용 답변의 중첩 목록이 조용히 사라진다 + // (실측: 부모 한 줄만 남았다). 그래서 거부 머리 줄 직후 구간에서만 걷어낸다. + assert.equal(kiroScrub('> - /x/tree\n - alpha.md\n - sub\n - sub/gamma.md'), + '- /x/tree\n - alpha.md\n - sub\n - sub/gamma.md'); + assert.equal(kiroScrub('> 차단됨.\nCommand fs_read is rejected because …:\n - /secret/**\n - /other/**\n계속.'), + '차단됨.\n계속.'); + // 추적 규칙은 경로·형식을 함께 요구한다 — 영문 산문이 통삭제되던 것(6라운드 부수 지적) + assert.equal(kiroScrub('> Reading files is safe.'), 'Reading files is safe.'); + assert.equal(kiroScrub('> Creating: a plan for Q3'), 'Creating: a plan for Q3'); + assert.equal(kiroScrub('> - Completed in 3 days'), '- Completed in 3 days'); + // 그래도 실제 추적(경로 포함)은 지운다 + assert.equal(kiroScrub('> 만들었습니다.\nCreating: /x/y.md\n - Completed in 0.1s'), '만들었습니다.'); + // 7라운드 HIGH — Batch 규칙만 도구명 요구가 빠져 "Batch processing finished." 같은 산문이 지워졌다 + assert.equal(kiroScrub('> Batch processing finished.\nBatch size is 32.'), 'Batch processing finished.\nBatch size is 32.'); + assert.equal(kiroScrub('Batch fs_read\n\u21b1 Operation 1: Reading file: /x, all lines> 결과.'), '결과.'); + // 7라운드 LOW — 거부 머리 줄 뒤 빈 줄이 구간을 유지해 다음 정상 중첩 불릿까지 지웠다 + assert.equal(kiroScrub('> 차단됨.\nCommand fs_read is rejected because …:\n - /secret/**\n\n정리 결과:\n - notes/a.md'), + '차단됨.\n\n정리 결과:\n - notes/a.md'); +}); + +test('러너 패리티 — 지시 블록이 펜스 없이도 파싱된다(kiro 렌더러가 펜스를 지운다)', async () => { + // 분리 검수 3라운드 HIGH 실증: kiro-cli는 렌더 결과에서 코드펜스를 제거한다(백틱 0개). 그러면 + // BLOCK_RE가 영원히 매치되지 않아 루틴 예약·쪽지·결재·커넥터가 **조용히 무동작**하고, 원시 JSON이 + // 사장 화면에 남는다 — cli-directives.mjs가 없애려 만들어진 바로 그 증상의 러너별 재발이자 + // "러너에 따라 크루가 할 수 있는 일이 갈리면 안 된다"(유건 지시)의 정면 위반이다. + const { parseDirectives } = await import('../src/cli-directives.mjs'); + const fenced = parseDirectives('앞말\n```argo\n{"action":"mail","to":"bob","message":"hi"}\n```\n뒷말'); + const bare = parseDirectives('앞말\nargo\n{\n "action": "mail",\n "to": "bob",\n "message": "hi"\n}\n뒷말'); + assert.equal(bare.directives.length, 1, '펜스 없는 지시가 파싱되지 않았다'); + assert.deepEqual(bare.directives, fenced.directives, '두 형태의 파싱 결과가 갈렸다'); + assert.equal(bare.clean, '앞말\n\n뒷말', '지시 블록이 화면에서 지워지지 않았다'); + // 실제 kiro 렌더 형태 — JSON이 **한 줄**로 나온다(실측). 여러 줄만 받으면 이게 새어 나간다. + const oneLine = parseDirectives('쪽지를 보냅니다.\n\nargo\n{"action":"mail","to":"bob","message":"검토 부탁"}'); + assert.equal(oneLine.directives.length, 1, '한 줄 JSON 지시가 파싱되지 않았다(실측 형태)'); + assert.equal(oneLine.clean, '쪽지를 보냅니다.'); + // 중첩 오브젝트(커넥터 args) — 정규식 게으른 매치가 안쪽 }에서 멈추던 자리 + const nested = parseDirectives('ok\nargo\n{"action":"tool","server":"gmail","tool":"s","args":{"q":"x","n":{"deep":1}}}\n뒷말'); + assert.equal(nested.directives.length, 1); + assert.deepEqual(nested.directives[0].args, { q: 'x', n: { deep: 1 } }, '중첩 args가 잘렸다'); + // 여러 블록의 **원문 순서** — 지시는 순차 실행되므로 뒤집히면 결과가 달라진다 + const two = parseDirectives('a\nargo\n{"action":"mail","to":"x"}\nb\nargo\n{"action":"schedule","every":"30m"}\nc'); + assert.deepEqual(two.directives.map((d) => d.action), ['mail', 'schedule'], '지시 순서가 뒤집혔다'); + // 일반 코드펜스 **안**의 지시 형태는 예시다 — 실행하면 크루가 "이렇게 쓰면 됩니다"라고 보여준 + // 예시가 실제 쪽지·예약이 된다(분리 검수 4라운드 MEDIUM, 전 러너 영향). + const example = parseDirectives('예시입니다.\n```\nargo\n{"action":"mail","to":"bob"}\n```\n이렇게 쓰세요.'); + assert.equal(example.directives.length, 0, '펜스 안 예시가 실행됐다'); + assert.match(example.clean, /"action"/, '예시 원문이 소실됐다'); + // 오탐 무해화 — action이 없으면 지시로 보지 않고 **원문을 보존**한다(산문 손실 금지). + const noise = parseDirectives('argo\n{\n "foo": 1\n}'); + assert.equal(noise.directives.length, 0); + assert.match(noise.clean, /"foo"/, 'action 없는 블록이 소실됐다'); +}); + +test('프롬프트 정직성 — 셸 없는 러너에 "셸 허용"이라 말하지 않는다', async () => { + // 분리 검수 3라운드 HIGH 실증: caps는 전권 상수(capabilities.mjs)라 caps.shell로는 판정할 수 없어 + // kiro 턴도 "셸 명령: 허용."을 받았다. 크루가 없는 도구를 시도하다 실패하고 사장에게 + // "터미널에서 직접 실행하세요"로 떠넘기는 것이 실제로 관측됐다. 크루가 이 문장의 1차 소비자다. + const { commonDirectives } = await import('../src/chat.mjs'); + const caps = { fs: true, browser: true, shell: true, bypass: true }; + for (const lang of ['ko', 'en']) { + const kiro = commonDirectives({ caps, hasTools: false, lang, runner: 'kiro' }); + assert.match(kiro, lang === 'ko' ? /셸 명령: 이 러너\(Kiro\)에서는 지원되지 않는다/ : /Shell commands: not supported on this runner \(Kiro\)/); + assert.doesNotMatch(kiro, lang === 'ko' ? /셸 명령: 허용/ : /Shell commands: allowed/); + // 셸 규율(백그라운드·타임아웃)도 주입하지 않는다 — 없는 도구의 사용법을 가르치는 자기모순 + assert.doesNotMatch(kiro, /nohup|run_in_background/, '셸 없는 러너에 백그라운드 규율이 주입됐다'); + // 지정 작업 폴더가 반경으로 적용되지 않는다는 단서(등록하면 열린다는 거짓 안내 방지) + assert.match(kiro, lang === 'ko' ? /반경으로 적용되지 않/ : /NOT applied as a radius/); + // 자기모순 금지 — "등록하면 열린다"와 "등록해도 안 열린다"를 같은 문단에 함께 쓰지 않는다(4R LOW) + assert.doesNotMatch(kiro, lang === 'ko' ? /등록하면 다음 턴부터 열린다/ : /it opens from the next turn/); + } + // 회귀 방지 — 셸이 있는 러너는 그대로 "허용"이어야 한다 + const codex = commonDirectives({ caps, hasTools: false, lang: 'ko', runner: 'codex' }); + assert.match(codex, /셸 명령: 허용/); + assert.doesNotMatch(codex, /반경으로 적용되지 않/); +}); diff --git a/test/runner-cred.test.mjs b/test/runner-cred.test.mjs index e82427fc..86f1a3a9 100644 --- a/test/runner-cred.test.mjs +++ b/test/runner-cred.test.mjs @@ -72,7 +72,7 @@ test('runnerStatus: cli 플래그 — 외부 CLI 러너만 true(카드 정직 // 크루 도구(쪽지·루틴·위임)는 SDK 러너 전용(chat.mjs hasTools:false) — UI가 이 플래그로만 // "미지원" 표기를 판정한다. 클라 하드코딩 표류('Claude Code' 명판 실사고 2026-07-20 계열) 방지. const st = await runnerStatus('credco'); - for (const id of ['codex', 'gemini', 'antigravity']) assert.equal(st[id].cli, true, `${id}=CLI 러너`); + for (const id of ['codex', 'gemini', 'antigravity', 'kiro']) assert.equal(st[id].cli, true, `${id}=CLI 러너`); for (const id of ['claude', 'glm', 'kimi', 'openrouter']) assert.equal(st[id].cli, false, `${id}=SDK 계열`); });