From f92a210beb5a449c246fed78c57bc9dded52862d Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 14:34:31 +0900 Subject: [PATCH 01/19] =?UTF-8?q?docs(#128-B2):=20link-guard=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EB=8B=9D=20=EC=84=A4=EA=B3=84=C2=B7=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EA=B3=84=ED=9A=8D(Codex=202=EC=B2=B4=ED=81=AC=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계+구현계획 — ignored-baseline 의 statSync(링크 추종) 5지점을 lstat/link-aware 로 전환해 symlink/junction 통한 워크스페이스 밖 read/write/삭제를 차단(advisory), path-guard helper 로 workspace-tools 와 통일. Codex 설계·계획 2라운드 독립 리뷰 반영(created-삭제 isLinkSync 정정· walk 비정형 표면화·새 symlink rollback·collect distinguisher). finding5 는 별도 PR 분리. Refs #128. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../2026-06-23-issue128-B2-link-guard.md | 846 ++++++++++++++++++ ...026-06-23-issue128-B2-link-guard-design.md | 101 +++ 2 files changed, 947 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md create mode 100644 docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md diff --git a/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md b/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md new file mode 100644 index 0000000..737adb1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md @@ -0,0 +1,846 @@ +# #128-B2 워크스페이스 격리: link-guard 하드닝 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `ignored-baseline.ts`의 FS 연산이 symlink/junction을 따라가 워크스페이스 밖을 읽거나 쓰지 않도록 link-aware로 전환하고, realpath containment helper를 `workspace-tools.ts`와 단일화한다(advisory 하드닝). + +**Architecture:** 신규 sync helper `path-guard.ts`(`isLinkSync` lstat 종류판정 + `resolveWithin` realpath containment)를 만들고, `ignored-baseline.ts`의 `statSync`(링크 추종) 5지점(walk·capture·collect·ancestor·leaf restore)을 `lstatSync`(비추종)로 바꿔 링크를 B1의 non-regular 분기에 흡수한다. `workspace-tools.ts`의 인라인 containment를 helper로 교체한다. + +**Tech Stack:** TypeScript(ESM, `node:fs` sync API), vitest, Electron 42 = Node 24.16.0. + +## Global Constraints + +- **런타임 Node = 24.16.0(Electron 42).** `lstatSync().isSymbolicLink()`가 POSIX symlink + Windows junction 둘 다 `true`(실증). junction은 admin 불요 생성. +- **leak-zero 불변식:** 표면화는 workspace-상대 경로 + reason/kind만. 링크 target(밖 경로)·파일 내용 절대 비노출. +- **fail-closed:** sensitive-명 링크·exotic reparse(lstat EINVAL/UNKNOWN)는 throw 또는 skip(진행 금지). 절대 fail-open 금지. +- **무회귀:** A(PR #127)·B1(PR #129)이 출하한 탐지·게이트·선택복원·non-regular 가드를 깨지 않는다. +- **5게이트 필수:** `npm run typecheck && npm run lint && npm run format:check && npm test && npm run build`. + `npm run brain` 갱신. +- **플랫폼 테스트:** junction = win32-only(`describe.skipIf(process.platform !== 'win32')`), POSIX symlink = POSIX-only(`if (process.platform === 'win32') return`). win32 vitest는 `win32 보안 회귀` required check가 강제. +- **커밋:** 태스크당 1커밋, prefix `feat(#128-B2):` / `test(#128-B2):` / `refactor(#128-B2):`. 커밋 메시지 푸터(Co-Authored-By 등)는 커밋 시 레포 규약대로 부착. + +## Setup (Task 0 전 1회) + +master 최신에서 피처 브랜치 생성: `git switch -c feat/128-b2-link-guard`. (subagent-driven-development/executing-plans가 worktree로 처리하면 그 안에서.) + +## File Structure + +- **Create** `src/main/core/workspace/path-guard.ts` — `isLinkSync`(lstat 종류판정), `resolveWithin`(realpath containment). 단일 책임: "경로의 링크 여부 + root 내부 여부 판정". +- **Create** `src/main/core/workspace/path-guard.test.ts`. +- **Modify** `src/main/core/workspace/ignored-baseline.ts` — `IgnoredBaseline.skipped` reason union에 `'symlink'` 추가; `listIgnored`(walk + top-level entry); `captureIgnoredBaseline`; `collectIgnoredChanges`; `clearNonDirAncestors`; `restoreIgnoredBaseline`(leaf + created). +- **Modify** `src/main/core/workspace/ignored-baseline.test.ts` — `[#128-B2]` 링크 테스트 추가. +- **Modify** `src/main/core/tools/workspace-tools.ts` — 인라인 `resolveWithin` 제거 → helper import; `read_file.classify`의 직접 `realpathSync` → helper 경유. +- **Modify** `src/main/core/tools/workspace-tools.test.ts` — 회귀 확인(필요 시 조정). + +--- + +### Task 1: `path-guard.ts` — `isLinkSync` + +**Files:** +- Create: `src/main/core/workspace/path-guard.ts` +- Test: `src/main/core/workspace/path-guard.test.ts` + +**Interfaces:** +- Produces: `isLinkSync(abs: string): 'regular' | 'dir' | 'link' | 'suspicious' | 'missing'` — `lstatSync` 기반(링크 비추종). `link`=symlink/junction, `suspicious`=비정형(FIFO 등) 또는 lstat EINVAL/UNKNOWN(exotic reparse·fail-closed), `missing`=ENOENT. + +- [ ] **Step 1: 실패 테스트 작성** + +```ts +// src/main/core/workspace/path-guard.test.ts +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as fs from 'node:fs' +import { isLinkSync } from './path-guard' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'fleet-pg-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('isLinkSync', () => { + it('일반 파일 → regular', () => { + writeFileSync(join(root, 'a.txt'), 'x') + expect(isLinkSync(join(root, 'a.txt'))).toBe('regular') + }) + it('디렉터리 → dir', () => { + mkdirSync(join(root, 'd')) + expect(isLinkSync(join(root, 'd'))).toBe('dir') + }) + it('미존재 → missing', () => { + expect(isLinkSync(join(root, 'nope'))).toBe('missing') + }) + it('lstat EINVAL/UNKNOWN(exotic reparse) → suspicious(fail-closed)', () => { + const err = Object.assign(new Error('einval'), { code: 'EINVAL' }) + const spy = vi.spyOn(fs, 'lstatSync').mockImplementation(() => { + throw err + }) + expect(isLinkSync(join(root, 'whatever'))).toBe('suspicious') + spy.mockRestore() + }) +}) + +describe.skipIf(process.platform === 'win32')('isLinkSync (POSIX symlink)', () => { + it('symlink → link', () => { + writeFileSync(join(root, 'target.txt'), 'x') + symlinkSync(join(root, 'target.txt'), join(root, 'link.txt')) + expect(isLinkSync(join(root, 'link.txt'))).toBe('link') + }) +}) + +describe.skipIf(process.platform !== 'win32')('isLinkSync (Windows junction)', () => { + it('junction → link', () => { + mkdirSync(join(root, 'realdir')) + symlinkSync(join(root, 'realdir'), join(root, 'jdir'), 'junction') + expect(isLinkSync(join(root, 'jdir'))).toBe('link') + }) +}) +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/workspace/path-guard.test.ts` +Expected: FAIL — `Failed to resolve import "./path-guard"`. + +- [ ] **Step 3: 최소 구현** + +```ts +// src/main/core/workspace/path-guard.ts +import { lstatSync } from 'node:fs' + +/** 경로의 종류를 lstat(링크 비추종)으로 판정한다. + * 'link' = POSIX symlink 또는 Windows junction(둘 다 lstat.isSymbolicLink()=true, 실증). + * 'suspicious' = FIFO/socket/device 등 비정형, 또는 lstat 가 EINVAL/UNKNOWN throw + * (OneDrive/AppExecLink 등 exotic reparse) — 안전상 따라가지 않음(fail-closed). + * 'missing' = ENOENT. */ +export type LinkKind = 'regular' | 'dir' | 'link' | 'suspicious' | 'missing' + +export function isLinkSync(abs: string): LinkKind { + try { + const st = lstatSync(abs) + if (st.isSymbolicLink()) return 'link' + if (st.isDirectory()) return 'dir' + if (st.isFile()) return 'regular' + return 'suspicious' + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return 'missing' + return 'suspicious' + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/workspace/path-guard.test.ts` +Expected: PASS (POSIX에선 junction describe skip, win32에선 symlink describe skip). + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/workspace/path-guard.ts src/main/core/workspace/path-guard.test.ts +git commit -m "feat(#128-B2): path-guard isLinkSync — lstat 기반 링크/종류 판정(junction 포함)" +``` + +--- + +### Task 2: `path-guard.ts` — `resolveWithin` (realpath containment) + +**Files:** +- Modify: `src/main/core/workspace/path-guard.ts` +- Test: `src/main/core/workspace/path-guard.test.ts` + +**Interfaces:** +- Produces: `resolveWithin(root: string, p: string): string` — `realpathSync.native(root)` 기준 정준화 + 최근접 존재 조상 realpath + 미존재 tail 재부착 + 정확 predicate(`rel===''` 허용 / `rel==='..'`·`..${sep}`·absolute 거부, win32 case-fold). root 내부면 정준 절대경로 반환, 밖이면 throw. root/조상 realpath 실패(exotic/UNC) → fail-closed throw. + +- [ ] **Step 1: 실패 테스트 작성** (`path-guard.test.ts`에 추가) + +```ts +import { resolveWithin } from './path-guard' + +describe('resolveWithin', () => { + it('root 내부 파일 허용 + 정준 경로 반환', () => { + writeFileSync(join(root, 'a.txt'), 'x') + const out = resolveWithin(root, 'a.txt') + expect(out.toLowerCase()).toContain('a.txt') + }) + it("'..foo' 같은 정상 in-root 이름을 오거부하지 않는다", () => { + writeFileSync(join(root, '..foo'), 'x') + expect(() => resolveWithin(root, '..foo')).not.toThrow() + }) + it("'../x' 상위 탈출은 거부", () => { + expect(() => resolveWithin(root, join('..', 'x'))).toThrow(/워크스페이스 밖/) + }) + it('미존재 leaf 도 컨테인먼트만 검사(허용)', () => { + expect(() => resolveWithin(root, 'sub/new.txt')).not.toThrow() + }) + it('root 자체(빈 상대) 허용', () => { + expect(() => resolveWithin(root, '.')).not.toThrow() + }) +}) + +describe.skipIf(process.platform === 'win32')('resolveWithin (POSIX symlink ancestor)', () => { + it('존재하는 symlink 조상 아래 미존재 tail 은 밖이면 거부', () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + symlinkSync(outside, join(root, 'esc'), 'dir') + expect(() => resolveWithin(root, 'esc/whatever.txt')).toThrow(/워크스페이스 밖/) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform !== 'win32')('resolveWithin (Windows junction ancestor)', () => { + it('junction 조상 아래 미존재 tail 은 밖이면 거부', () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + symlinkSync(outside, join(root, 'esc'), 'junction') + expect(() => resolveWithin(root, 'esc/whatever.txt')).toThrow(/워크스페이스 밖/) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/workspace/path-guard.test.ts` +Expected: FAIL — `resolveWithin is not a function` / import 실패. + +- [ ] **Step 3: 최소 구현** (`path-guard.ts`에 추가) + +```ts +import { lstatSync, realpathSync, existsSync } from 'node:fs' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' + +// win32 비교는 case-insensitive(NTFS) — 양변 case-fold. +const fold = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p) + +/** root realpath 기준으로 p 를 정준 절대경로로 해소하고 root 내부인지 검사한다. + * lexical 비교는 symlink 비해소라 무력 → realpath 필수. 미존재 leaf 는 + * "최근접 존재 조상 realpath + 미존재 tail 재부착"으로 symlink 조상 탈출도 잡는다. + * realpath 실패(exotic reparse/UNC) 또는 root 밖 → throw(fail-closed). */ +export function resolveWithin(root: string, p: string): string { + let realRoot: string + try { + realRoot = realpathSync.native(root) + } catch (err) { + throw new Error(`워크스페이스 realpath 해소 불가(운영 에러): ${root}`, { cause: err }) + } + const abs = resolve(realRoot, p) + // 최근접 존재 조상까지 올라가 그 조상의 realpath 를 구하고 미존재 tail 을 재부착한다. + let existingAbs = abs + const tail: string[] = [] + while (!existsSync(existingAbs)) { + tail.unshift(basename(existingAbs)) + const parent = dirname(existingAbs) + if (parent === existingAbs) break + existingAbs = parent + } + let realCandidate: string + try { + const realExisting = realpathSync.native(existingAbs) + realCandidate = tail.length ? resolve(realExisting, ...tail) : realExisting + } catch (err) { + throw new Error(`경로 realpath 해소 실패(안전상 거부): ${p}`, { cause: err }) + } + const rel = relative(fold(realRoot), fold(realCandidate)) + const inside = + rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) + if (!inside) throw new Error(`경로가 워크스페이스 밖입니다: ${p}`) + return realCandidate +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/workspace/path-guard.test.ts` +Expected: PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/workspace/path-guard.ts src/main/core/workspace/path-guard.test.ts +git commit -m "feat(#128-B2): path-guard resolveWithin — realpath.native containment(미존재 tail·case-fold·정확 predicate)" +``` + +--- + +### Task 3: `ignored-baseline.ts` — `listIgnored` walk + top-level 엔트리 link-aware + +**Files:** +- Modify: `src/main/core/workspace/ignored-baseline.ts` (`IgnoredBaseline.skipped` union L49; `listIgnored` walk L93-130, top-level loop L131-143) +- Test: `src/main/core/workspace/ignored-baseline.test.ts` + +**Interfaces:** +- Consumes: `isLinkSync` (Task 1). +- Produces: `listIgnored` 가 symlink/junction을 재귀/수집하지 않고 `skipped{reason:'symlink'}`로 기록. `IgnoredBaseline.skipped[].reason`에 `'symlink'` 추가. + +- [ ] **Step 1: 실패 테스트 작성** (`ignored-baseline.test.ts`에 추가; 파일 상단 import에 `symlinkSync` 추가) + +```ts +// describe('captureIgnoredBaseline', ...) 내부 또는 별도 describe 에 추가 +describe.skipIf(process.platform === 'win32')('[#128-B2] symlink 비추종 (POSIX)', () => { + it('git-보고 ignored 가 symlink-to-dir 면 재귀 안 하고 밖을 수집 안 한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'secret.txt'), 'SECRET') + symlinkSync(outside, join(root, 'link'), 'dir') + const git = fakeGitIgnored(['link/']) // git 이 디렉터리처럼 보고 + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + // 밖의 secret.txt 가 절대 entries 에 들어오면 안 됨 + expect([...base.entries.keys()].some((k) => k.includes('secret'))).toBe(false) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform !== 'win32')('[#128-B2] junction 비추종 (Windows)', () => { + it('git-보고 ignored 가 junction 이면 재귀 안 하고 밖을 수집 안 한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'secret.txt'), 'SECRET') + symlinkSync(outside, join(root, 'link'), 'junction') + const git = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect([...base.entries.keys()].some((k) => k.includes('secret'))).toBe(false) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts -t "#128-B2"` +Expected: FAIL on the current platform's case — `statSync`가 링크를 추종해 밖의 `secret.txt`가 entries에 들어옴. + +- [ ] **Step 3: 구현** + +3-1. `IgnoredBaseline.skipped` union(L49)에 `'symlink'` 추가: +```ts + skipped: { path: string; reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' }[] +``` + +3-2. `listIgnored` 의 반환 `skipped` 타입(L65)과 walk/top-level을 link-aware로. 파일 상단 import에 `lstatSync` 추가, helper import 추가: +```ts +import { ... , lstatSync, ... } from 'node:fs' +import { isLinkSync } from './path-guard' +``` +walk 의 dirent 루프(현 L109-129)를 `withFileTypes`로 교체: +```ts + const walk = (relDir: string): void => { + if (capped) return + let entries: import('node:fs').Dirent[] + try { + entries = readdirSync(resolve(root, relDir), { withFileTypes: true }) + } catch { + if (!capped) { + capped = true + skipped.push({ path: SCAN_CAPPED, reason: 'over-cap' }) + } + return + } + for (const ent of entries) { + if (capped) return + const rel = `${relDir}/${ent.name}` + if (ent.isSymbolicLink()) { + // [#128-B2] symlink/junction 은 따라가지 않는다 — 밖을 가리켜 읽거나 재귀하지 않음. + skipped.push({ path: rel.replace(/\\/g, '/'), reason: 'symlink' }) + continue + } + if (ent.isDirectory()) { + const relSlash = `${rel}/` + if (policy.denylistRe.test(rel) || policy.denylistRe.test(relSlash)) continue + walk(rel) + continue + } + // [#128-B2] 파일 + 비정형(FIFO/socket/device) → pushFile. capture/collect 의 lstat 가 + // regular 면 백업, 아니면 'not-regular' 로 표면화한다(B1 동작 보존 — silent drop 금지). + pushFile(rel) + } + } +``` +top-level 엔트리 루프(현 L131-143)에서 비-`/` 엔트리도 링크 검사: +```ts + for (const e of ignored) { + if (capped) break + const rel = e.replace(/\\/g, '/') + if (rel.endsWith('/')) { + const dir = rel.replace(/\/+$/, '') + if (policy.denylistRe.test(`${dir}/`)) continue + // [#128-B2] git 이 디렉터리로 보고해도 실제 junction/symlink 면 재귀 금지. + if (isLinkSync(resolve(root, dir)) === 'link') { + skipped.push({ path: dir, reason: 'symlink' }) + continue + } + walk(dir) + } else { + // [#128-B2] 파일로 보고된 엔트리가 실제 링크면 수집 안 함(capture 의 lstat 가 이중 방어). + if (isLinkSync(resolve(root, rel)) === 'link') { + skipped.push({ path: rel, reason: 'symlink' }) + continue + } + pushFile(rel) + } + } +``` +**`'symlink'` reason union — 전 사이트 갱신(Codex #3, 누락 방지):** +1. `IgnoredBaseline.skipped[].reason`(L49) → `'over-cap'|'read-failed'|'not-regular'|'symlink'` +2. `listIgnored()` 반환 타입(L65) `skipped: {…reason: 'over-cap'|'symlink'}[]` +3. `listIgnored()` 내부 `skipped` local(L72) 동일 +4. `captureIgnoredBaseline()`의 `skipped` local(L154) — 이미 `IgnoredBaseline['skipped']` 형이면 1과 동기 +5. `collectIgnoredChanges()` `unrestorable` merge(L233-239) — path 기준 dedup이라 reason 추가 무영향(Codex 확인) +6. `restoreIgnoredBaseline()` skipped 처리(L326-342) — `SCAN_CAPPED` 필터 유지(reason 추가 무충돌, Codex 확인) +capture/collect/restore가 `enumSkipped`/`currentSkipped`/`skipped`를 그대로 누적하므로 'symlink'도 자동 표면화된다. + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts` +Expected: PASS (기존 테스트 + #128-B2). `secret.txt`가 entries에 없음. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/workspace/ignored-baseline.ts src/main/core/workspace/ignored-baseline.test.ts +git commit -m "feat(#128-B2): listIgnored walk/엔트리 link-aware — symlink/junction 재귀·수집 차단" +``` + +--- + +### Task 4: `captureIgnoredBaseline` — `statSync`→`lstatSync` (링크 leaf 비-read) + +**Files:** +- Modify: `src/main/core/workspace/ignored-baseline.ts` (`captureIgnoredBaseline` L158-200) +- Test: `src/main/core/workspace/ignored-baseline.test.ts` + +**Interfaces:** +- Consumes: 없음(기존 B1 non-regular 분기 재사용). +- Produces: 링크 ignored 엔트리는 read 없이 처리 — sensitive-명 → throw, else → `skipped{reason:'symlink'}`. + +- [ ] **Step 1: 실패 테스트 작성** + +```ts +describe.skipIf(process.platform === 'win32')('[#128-B2] capture 링크 leaf (POSIX)', () => { + it('비-sensitive symlink ignored 파일은 read 없이 symlink 로 skip', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'secret.txt'), 'SECRET') + symlinkSync(join(outside, 'secret.txt'), join(root, 'link.dat')) + const git = fakeGitIgnored(['link.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect(base.entries.has('link.dat')).toBe(false) + expect(base.skipped).toContainEqual({ path: 'link.dat', reason: 'symlink' }) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + it('sensitive-명 symlink 는 throw(fail-closed)', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'k'), 'KEY') + symlinkSync(join(outside, 'k'), join(root, '.env')) + const git = fakeGitIgnored(['.env']) + await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( + /링크|일반 파일이 아님/, + ) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts -t "capture 링크 leaf"` +Expected: FAIL — `statSync` 추종으로 `link.dat`이 regular로 보여 백업되고, `.env` symlink target이 읽혀 throw 안 함. + +- [ ] **Step 3: 구현** (`captureIgnoredBaseline` 내부, 현 L162-175) + +`statSync`→`lstatSync`로 바꾸고, B1 non-regular 분기 직전에 symlink 분기를 명시: +```ts + let st + try { + st = lstatSync(abs) // [#128-B2] 링크 비추종 — 링크면 isFile()=false 로 아래 분기 적중 + } catch (err) { + if (sensitive) throw new Error(`민감 ignored 파일 stat 실패: ${path}`, { cause: err }) + skipped.push({ path, reason: 'read-failed' }) + continue + } + // [#128-B2] symlink/junction → read 금지(밖 target 유출 차단). sensitive 면 fail-closed. + if (st.isSymbolicLink()) { + if (sensitive) throw new Error(`민감 ignored 파일이 링크임(백업 불가): ${path}`) + skipped.push({ path, reason: 'symlink' }) + continue + } + // [#128-A] non-regular(FIFO/socket/device/dir) + if (!st.isFile()) { + if (sensitive) throw new Error(`민감 ignored 파일이 일반 파일이 아님(백업 불가): ${path}`) + skipped.push({ path, reason: 'not-regular' }) + continue + } +``` + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts` +Expected: PASS (기존 + 신규). + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/workspace/ignored-baseline.ts src/main/core/workspace/ignored-baseline.test.ts +git commit -m "feat(#128-B2): captureIgnoredBaseline lstat — 링크 leaf read 차단(밖 비밀 유출 방지)" +``` + +--- + +### Task 5: `collectIgnoredChanges` — `statSync`→`lstatSync` (링크 교체 비-read) + +**Files:** +- Modify: `src/main/core/workspace/ignored-baseline.ts` (`collectIgnoredChanges` L257-271) +- Test: `src/main/core/workspace/ignored-baseline.test.ts` + +**Interfaces:** +- Produces: baseline 일반파일이 실행 중 symlink로 교체되면 read 없이 `modified`(backup 있으면 restorable). + +- [ ] **Step 1: 실패 테스트 작성** + +```ts +describe.skipIf(process.platform === 'win32')('[#128-B2] collect 링크 교체 (POSIX)', () => { + // 깨끗한 distinguisher: symlink target 내용을 baseline 과 *동일*하게 둔다. + // - old(statSync 추종): target('orig')을 읽어 hash 가 baseline 과 일치 → '변경 없음' 오판(보안 구멍). + // - new(lstat 비추종): isSymbolicLink → modified. (단순히 다른 내용을 쓰면 old 도 modified 라 거짓-green.) + it('baseline 파일이 같은 내용 가리키는 symlink 로 교체돼도 modified 로 잡는다(링크 비추종)', async () => { + writeFileSync(join(root, 'f.dat'), 'orig') + const git = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'same'), 'orig') // target 내용 == baseline + rmSync(join(root, 'f.dat')) + symlinkSync(join(outside, 'same'), join(root, 'f.dat')) + const cs = await collectIgnoredChanges(root, git, base, DEFAULT_IGNORED_POLICY) + expect(cs.changes).toContainEqual({ path: 'f.dat', change: 'modified', sensitive: false }) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts -t "collect 링크 교체"` +Expected: FAIL — old `statSync`가 symlink target('orig')을 읽어 hash 가 baseline 과 일치 → `modified` 미-push → 단언 실패. (new: lstat → isSymbolicLink → modified.) + +- [ ] **Step 3: 구현** (`collectIgnoredChanges` 내부, 현 L256-271) + +`statSync`→`lstatSync`로 바꾸고 symlink 분기를 non-regular 직전에 명시: +```ts + let st + try { + st = lstatSync(abs) // [#128-B2] 링크 비추종 + } catch { + changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + unrestorable.push({ path, reason: 'stat-failed' }) + continue + } + if (st.isSymbolicLink()) { + // [#128-B2] baseline 일반파일이 링크로 교체됨 = modified. read 안 함(밖 유출 차단). + // backup 있으면 restore 가 링크 제거 후 복원 → unrestorable 아님. + changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) + continue + } + if (!st.isFile()) { + changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) + continue + } +``` +(이하 size cap·hash 재계산 로직은 regular 경로 그대로 유지.) + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts` +Expected: PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/workspace/ignored-baseline.ts src/main/core/workspace/ignored-baseline.test.ts +git commit -m "feat(#128-B2): collectIgnoredChanges lstat — 링크로 교체된 baseline 파일 비-read modified" +``` + +--- + +### Task 6: `restoreIgnoredBaseline` — 쓰기 측 link-aware (ancestor + leaf + created) + +**Files:** +- Modify: `src/main/core/workspace/ignored-baseline.ts` (`clearNonDirAncestors` L298-318; restore created 삭제 L332-342; leaf write L344-359) +- Test: `src/main/core/workspace/ignored-baseline.test.ts` + +**Interfaces:** +- Consumes: `isLinkSync` (Task 1). +- Produces: restore 가 링크 조상/leaf/created 를 따라가 밖에 쓰거나 지우지 않는다. + +- [ ] **Step 1: 실패 테스트 작성** + +```ts +describe.skipIf(process.platform === 'win32')('[#128-B2] restore 쓰기 측 link-guard (POSIX)', () => { + it('symlink leaf 는 링크를 제거하고 root 안 실파일로 복원(밖 target 미오염)', async () => { + writeFileSync(join(root, 'f.dat'), 'orig') + const git = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'victim'), 'DO-NOT-OVERWRITE') + rmSync(join(root, 'f.dat')) + symlinkSync(join(outside, 'victim'), join(root, 'f.dat')) + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + // 밖 victim 은 그대로, root/f.dat 은 backup('orig')으로 복원 + expect(readFile(join(outside, 'victim')).toString()).toBe('DO-NOT-OVERWRITE') + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + // NOTE: 이 created 테스트는 회귀-lock 이다(POSIX 에선 old `rmSync{recursive}` 도 링크만 제거 — Codex 실증). + // genuinely-red 보장은 위 leaf 테스트(old statSync 가 밖 victim 을 덮어씀). 이 테스트는 의도 고정 + + // win32 junction(recursive-rm 이 target 내용을 지울 위험) 보장을 `win32 보안 회귀` 잡에서 검증한다. + it('created symlink-to-dir 는 링크만 unlink(밖 디렉터리 내용 보존)', async () => { + const git = fakeGitIgnored(['f.dat', 'esc']) // f.dat=baseline, esc=created link + writeFileSync(join(root, 'f.dat'), 'orig') + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'keep'), 'KEEP') + symlinkSync(outside, join(root, 'esc'), 'dir') // 실행 중 생성된 링크 + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + expect(existsSync(join(root, 'esc'))).toBe(false) // 링크 제거됨 + expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') // 밖 내용 보존 + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + + // Codex #3: 실행 중 새로 생긴 escaping symlink 가 rollback 삭제에서 빠지지 않는지 고정한다. + // (current-scan 'symlink' skipped 를 skippedPaths 에 넣으면 누락되는 회귀를 가드 — skippedPaths 는 + // baseline.skipped 만이어야 한다. restore 의 skipped 루프가 removeCreated 로 unlink.) + it('실행 중 새로 생긴 escaping symlink 는 rollback 에서 unlink(밖 내용 보존)', async () => { + writeFileSync(join(root, 'f.dat'), 'orig') + const baseGit = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'keep'), 'KEEP') + symlinkSync(outside, join(root, 'newlink'), 'dir') // 에이전트가 새로 만든 링크 + const git = fakeGitIgnored(['f.dat', 'newlink']) // restore 시점 git 보고 + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + expect(existsSync(join(root, 'newlink'))).toBe(false) + expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) +``` + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts -t "restore 쓰기 측"` +Expected: FAIL — symlink leaf 에서 `statSync().isFile()=true`라 `rmSync` 없이 `writeFileSync`가 밖 `victim`을 'orig'로 덮어씀. + +- [ ] **Step 3: 구현** + +3-1. import에 `isLinkSync` 사용(Task 3에서 이미 import). `clearNonDirAncestors`(L310-317) `statSync`→`lstatSync` + symlink 명시: +```ts + let cur = root + for (const part of relDir.split(/[\\/]/).filter(Boolean)) { + cur = resolve(cur, part) + if (!existsSync(cur)) continue + let ls + try { + ls = lstatSync(cur) // [#128-B2] existsSync→lstat race 는 fail-safe(advisory) + } catch { + continue + } + // [#128-B2] 비-dir 또는 링크(junction 포함) 조상은 제거 → mkdirSync(recursive) 가 root 안 체인 재생성. + if (!ls.isDirectory() || ls.isSymbolicLink()) { + rmSync(cur, { recursive: true, force: true }) + return + } + } +``` + +3-2. created 삭제(L332-342)를 link-aware unlink로. **resolveWithin(realpath 추종)은 쓰지 않는다 — 탈출 링크에서 realpath 가 throw 해 unlink 자체가 막힌다(Codex 제안 정정). 경로는 git-상대라 lexical `resolve(root,…)`로 컨테인먼트가 보장된다.** +```ts + const removeCreated = (rel: string): void => { + const abs = resolve(root, rel) + // [#128-B2] lexical containment(realpath 아님 — 탈출 링크도 unlink 해야 하므로 realpath 추종 금지). + // git-상대 경로는 보통 root 아래지만, 이상한 절대/상위 경로(테스트 double 등) 방어(Codex #1). + const r = relative(root, abs) + if (r === '..' || r.startsWith(`..${sep}`) || isAbsolute(r)) return + // 링크면 recursive 금지 — 링크 자체만 unlink(밖 내용 보존). isLinkSync=lstat(비추종). + rmSync(abs, isLinkSync(abs) === 'link' ? { force: true } : { recursive: true, force: true }) + } + for (const path of files) { + if (baseline.entries.has(path) || skippedPaths.has(path)) continue + removeCreated(path) + } + for (const s of skipped) { + if (s.path === SCAN_CAPPED) continue + if (baseline.entries.has(s.path) || skippedPaths.has(s.path)) continue + removeCreated(s.path) + } +``` + +3-3. leaf write(L350-356) `statSync`→`lstatSync`: +```ts + if (existsSync(abs)) { + const st = lstatSync(abs) // [#128-B2] 비추종 — symlink leaf 는 isFile()=false 로 제거 대상 + if (!st.isFile()) { + rmSync(abs, { recursive: true, force: true }) // 링크/디렉터리 제거 후 실파일로 복원 + } + } + writeFileSync(abs, entry.backup) + chmodSync(abs, entry.mode) +``` + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/workspace/ignored-baseline.test.ts` +Expected: PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/workspace/ignored-baseline.ts src/main/core/workspace/ignored-baseline.test.ts +git commit -m "feat(#128-B2): restore 쓰기 측 link-aware — 조상/leaf/created 링크 통한 밖 쓰기·삭제 차단" +``` + +--- + +### Task 7: `workspace-tools.ts` — 인라인 containment를 helper로 통일 + +**Files:** +- Modify: `src/main/core/tools/workspace-tools.ts` (인라인 `resolveWithin` L27-39 제거; `read_file.classify` L79-83) +- Test: `src/main/core/tools/workspace-tools.test.ts` (회귀 확인) + +**Interfaces:** +- Consumes: `resolveWithin` (Task 2). + +- [ ] **Step 1: 실패 테스트 작성** — 기존 동작 유지 + `..foo` 오거부 회귀 방지: + +```ts +// workspace-tools.test.ts 에 추가(read_file 또는 list_directory 도구 사용) +it('[#128-B2] resolveWithin 통일 — "..foo" 정상 파일 읽기 가능(오거부 회귀 방지)', async () => { + // root 는 기존 테스트 헬퍼가 만든 임시 워크스페이스 사용 + writeFileSync(join(root, '..foo'), 'hello') + const tools = createWorkspaceReadTools(root) + const readFileTool = tools.find((t) => t.definition.name === 'read_file')! + const out = await readFileTool.execute({ path: '..foo' }, { signal: undefined } as never) + expect(out).toBe('hello') +}) +``` +(주의: 기존 테스트 파일의 `root` fixture·`createWorkspaceReadTools` import·`ctx` 형태에 맞춰 작성. 기존 "밖 경로 throw" 테스트가 있으면 유지된다.) + +- [ ] **Step 2: 실패 확인** + +Run: `npx vitest run src/main/core/tools/workspace-tools.test.ts -t "#128-B2"` +Expected: FAIL — 현 인라인 `resolveWithin`의 `rel.startsWith('..')`가 `..foo`를 밖으로 오판해 throw. + +- [ ] **Step 3: 구현** + +3-1. 인라인 `resolveWithin`(L27-39) 삭제, import 추가: +```ts +import { resolveWithin } from '../workspace/path-guard' +``` +3-2. 호출부는 sync 반환에 `await` 무해 — 변경 없음(예: `const abs = await resolveWithin(root, p)`). (lint이 `await` on non-promise를 경고하면 `await` 제거.) +3-3. `read_file.classify`(L79-83)의 직접 `realpathSync(...)` 민감도 승격을 helper 경유로 정합(밖이면 throw 대신 분류 목적이라 try/catch 유지): +```ts + try { + // resolveWithin 은 밖이면 throw → 분류 단계에선 무시(execute 가 처리). 안이면 정준 경로로 민감도 판정. + if (SENSITIVE_FILE.test(resolveWithin(root, p))) return 'destructive' + } catch { + /* 미존재/밖 — execute 의 resolveWithin/stat 가 처리 */ + } +``` + +- [ ] **Step 4: 통과 확인** + +Run: `npx vitest run src/main/core/tools/workspace-tools.test.ts` +Expected: PASS (기존 컨테인먼트/symlink 테스트 + 신규). + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/core/tools/workspace-tools.ts src/main/core/tools/workspace-tools.test.ts +git commit -m "refactor(#128-B2): workspace-tools containment를 path-guard helper로 통일(startsWith 오거부 수정)" +``` + +--- + +### Task 8: 문서화 주석 + `npm run brain` + 5게이트 + +**Files:** +- Modify: `src/main/core/workspace/ignored-baseline.ts`(파일 상단 docblock), `path-guard.ts`(docblock) +- Modify: `brain.md`(생성물) + +- [ ] **Step 1: TOCTOU/격리 한계 주석 추가** — `path-guard.ts` 상단에 docblock: + +```ts +// "경로검사 ≠ 격리"(advisory). 이 모듈은 *Fleet 자체 FS 연산*이 symlink/junction 을 따라가 +// 워크스페이스 밖을 읽거나 쓰는 것을 줄이는 advisory guard다. 스폰된 CLI 의 직접 쓰기는 막지 못하며, +// lstat→open/write 사이 TOCTOU 창이 남는다(순수 Node 는 openat2/O_NOFOLLOW 크로스플랫폼 부재 — +// Windows 엔 O_NOFOLLOW 자체 없음). 강한 격리는 OS/CLI 샌드박스 층(#128 향후·문서 참조). +``` + +- [ ] **Step 2: brain 갱신** + +Run: `npm run brain` +Expected: `brain.md`가 신규 `path-guard.ts`/변경 반영하여 갱신됨(diff 발생). + +- [ ] **Step 3: 5게이트 전체 실행** + +Run: `npm run typecheck && npm run lint && npm run format:check && npm test && npm run build` +Expected: 전부 통과. (`format:check` 실패 시 `npm run format` 후 재실행·재커밋.) + +- [ ] **Step 4: 커밋** + +```bash +git add -A +git commit -m "docs(#128-B2): TOCTOU/격리 한계 주석 + brain 갱신" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §0 helper `isLinkSync`/`resolveWithin` → Task 1·2 ✓ +- §1 listIgnored walk + 엔트리 → Task 3 ✓ / capture lstat → Task 4 ✓ / collect lstat → Task 5 ✓ / restore(ancestor·leaf·created) → Task 6 ✓ +- §2 workspace-tools 통일 + classify → Task 7 ✓ +- 표면화(`'symlink'` reason, leak-zero) → Task 3·4 ✓ +- root-symlink 정책(realpath.native 기준) → Task 2 구현·테스트 ✓ +- 문서화(TOCTOU) → Task 8 ✓ + 각 함수 주석(Task 3-6) ✓ +- 테스트(win32 junction/POSIX symlink·5게이트·brain) → 각 Task + Task 8 ✓ +- 비목표 finding5 분리 → 계획 범위에서 제외 ✓ + +**2. Placeholder scan:** 모든 Step에 실제 코드/명령/기대출력 포함. TODO/TBD 없음. ✓ + +**3. Type consistency:** `isLinkSync(): LinkKind`(Task 1)을 Task 3(top-level·`==='link'`)·Task 6(created·`==='link'`)에서 동일 시그니처로 사용. `resolveWithin(root,p): string`(Task 2)을 Task 7에서 동일 사용. `skipped[].reason`에 `'symlink'` 추가(Task 3)를 capture(Task 4)·collect가 일관 사용. ✓ + +**4. Codex 정정 반영:** created-삭제는 `resolveWithin`(realpath 추종)이 아니라 `isLinkSync`(lstat)만 사용 — 탈출 링크에서 realpath throw로 unlink가 막히는 문제를 피함(Task 6 Step 3-2에 근거 명시). + +## 영향 파일 요약 + +신규 2(`path-guard.ts`+test) · 수정 4(`ignored-baseline.ts`+test, `workspace-tools.ts`+test) · 생성물 1(`brain.md`). diff --git a/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md b/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md new file mode 100644 index 0000000..ad1fef6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md @@ -0,0 +1,101 @@ +# #128-B2 설계 — 워크스페이스 격리: link-guard 하드닝 (advisory · Codex 리뷰 반영판) + +> **이슈:** #128 (부모 #123·#27) · **선행:** #128-B1 머지(PR #129, `35a4b97`) +> **체크포인트:** 설계 코멘트 [#4775522688](https://github.com/pdw96/fleet/issues/128#issuecomment-4775522688) + Codex 독립 리뷰(이슈 #128 스레드, chatgpt-codex-connector 2026-06-23) 반영. +> **날짜:** 2026-06-23 + +## 배경 / 스코프 + +#128 §1 원안의 "프로세스 수준 워크스페이스 격리"(symlink/junction 차단 또는 강한 sandbox 경로로만 실행 + 순차→disposable worktree)를 세 갈래 결정으로 **advisory 하드닝**으로 재정의한다. + +1. **위협모델 = 탐지·완화(advisory).** 주 위협 = 우발/게으른 워크스페이스 탈출. 결정적 적대자 보장은 **비목표**(문서화). OS/CLI 샌드박스 신규 배선 안 함. +2. **순차 실행 = 직접편집 유지.** disposable worktree 전환 안 함. advisory 모델에선 worktree로 옮겨도 적대자 보장이 안 생기고(worktree 밖 symlink 가능) UX·복잡도만 ↑. +3. **구현 = link-guard 하드닝 + helper 통일.** 그린필드 아님 — `workspace-tools.ts`엔 검증된 link-guard(`resolveWithin` realpath containment, `walk`의 Dirent symlink-skip)가 이미 있는데 `ignored-baseline.ts`만 `statSync`(링크 추종)로 이탈. B2 = 이탈한 쪽을 표준으로 끌어올리고 helper를 단일화. + +**핵심 보안 동기.** 현재 `ignored-baseline`의 walk/capture/collect/restore가 전부 `statSync`(링크 추종)를 쓴다. 워크스페이스 안에 `~/.ssh/id_rsa`를 가리키는 ignored symlink/junction을 심으면 Fleet 자체가 그걸 따라가 `readFileSync`로 읽어 백업 Buffer에 담고, restore 시 `writeFileSync`/`rmSync`로 워크스페이스 밖에 쓰거나 지운다. B1의 non-regular 가드는 `statSync`가 링크를 따라가 `isFile()=true`로 보고하므로 이 벡터를 못 잡는다. + +## Feasibility (GREEN — 실증 + 문서 검증) + +win32 머신(Node v24.16.0 = Electron 42 런타임)에서 junction/symlink fixture 실증 + 3-에이전트 리서치(context7 Node 문서·containment prior art·CLI sandbox 지형)로 교차검증. + +| 대상 | `lstat().isSymbolicLink()` | `statSync()`(링크 추종) | `realpath` | `Dirent.isSymbolicLink()` | +|---|---|---|---|---| +| **Windows junction**(admin 불요 생성) | ✅ `true`, isDir/isFile=false | `isDirectory()=true`(밖 추종) | 밖 target 해소 | ✅ `true` | +| 일반 파일 | false | isFile=true | 자기 자신 | false | + +- **탐지 primitive 확정**: `lstat`/`Dirent`의 `isSymbolicLink()`가 POSIX symlink + Windows junction(reparse mount point) 둘 다 신뢰성 있게 잡는다(Node 18~24 안정 — libuv가 `IO_REPARSE_TAG_SYMLINK`·`IO_REPARSE_TAG_MOUNT_POINT`를 S_IFLNK로 매핑). junction은 admin 불요 생성 → win32 CI 실 fixture 가능. +- **주의**: junction↔symlink 구분 API 없음(둘 다 "링크"). OneDrive/AppExecLink 등 exotic reparse는 `lstat`가 EINVAL/UNKNOWN throw → try/catch로 `suspicious`(fail-closed). +- **containment**: lexical 비교는 symlink 비해소라 무력. `realpathSync.native(root)` 정준화(드라이브 케이스·8.3 단축명) + "최근접 존재 조상 realpath + 미존재 tail 재부착" + 정확 predicate + win32 case-fold가 검증된 공식(is-path-inside / node-tar / zip-slip). +- **TOCTOU**: 순수 Node로 완전차단 불가 — `O_NOFOLLOW`는 Windows에 없고(POSIX 전용·leaf만 보호), `openat2(RESOLVE_BENEATH)` 바인딩 없음. → advisory로 문서화. + +## 컴포넌트 설계 + +### 0. 공유 helper — `src/main/core/workspace/path-guard.ts` (신규, sync) + +```ts +// lstat 기반 링크/종류 판정. EINVAL/UNKNOWN(exotic reparse) → suspicious(fail-closed). +type LinkKind = 'regular' | 'dir' | 'link' | 'suspicious' | 'missing' +export function isLinkSync(abs: string): LinkKind + +// realpath 기반 containment. 밖이면 throw. (workspace-tools·옵션으로 ignored-baseline 사용) +export function resolveWithin(root: string, p: string): string +// 비-throw 변형(skip+surface 용도) +export function escapesRoot(root: string, abs: string): boolean +``` + +- `isLinkSync`: `lstatSync(abs)` → `isSymbolicLink()`면 `link`; `isDirectory()`면 `dir`; `isFile()`이면 `regular`; catch에서 `err.code` ENOENT→`missing`, EINVAL/UNKNOWN→`suspicious`, 기타→`suspicious`(보수적). +- `resolveWithin` 알고리즘(Codex 권장 5단계): + 1. `realRoot = realpathSync.native(root)` (root가 symlink여도 정준 경로 기준 — **root realpath 기준 고정**, 아래 §정책). + 2. `abs = resolve(realRoot, p)`. + 3. `abs`에서 존재하는 최근접 조상까지 올라가 그 조상의 `realpathSync.native` 계산. + 4. 미존재 tail을 lexical로 재부착. + 5. `rel = relative(realRoot, realCandidate)` (win32은 양변 case-fold). predicate: `rel === ''` 허용 / `rel === '..'`·`rel.startsWith('..'+sep)`·`isAbsolute(rel)` 거부. +- `realpathSync.native`가 root/존재 조상에서 throw → fail-closed(명확한 에러 메시지: root 자체 실패는 "워크스페이스 realpath 해소 불가" 운영 에러). +- **sync 코어**: `ignored-baseline`(전부 sync) 직접 사용. `workspace-tools`의 `await resolveWithin(...)`은 sync 반환에 await가 무해 no-op이라 시그니처만 sync로 바꿔도 호출부 무수정. + +### 1. `ignored-baseline.ts` 하드닝 (B1 분기 재사용) + +**전수 스윕 — `statSync` 5지점**(Codex 권장 #2): list walk · capture · collect · ancestor cleanup · leaf restore. 전부 link-aware로. + +- **`listIgnored.walk`**: `statSync().isDirectory()`(추종) → `readdirSync(resolve(root,relDir), { withFileTypes:true })` + `Dirent.isSymbolicLink()`. 링크면 재귀/수집 안 함, `skipped{reason:'symlink'}`. 최상위 git-보고 ignored 엔트리(`for (const e of ignored)`)도 `isLinkSync`로 링크면 skip+표면화(파일·디렉터리 가정 전). +- **`captureIgnoredBaseline`**: `statSync(abs)`→`lstatSync(abs)`. 링크 = `isFile()=false` → **B1 non-regular 분기 자동 적중**(sensitive→throw fail-closed / else→`skipped{reason:'not-regular'}`). 링크 target read 안 함 → 비밀 유출 차단. size cap·hash는 regular(lstat==stat)에서 그대로. +- **`collectIgnoredChanges`**: `statSync(abs)`→`lstatSync(abs)`. baseline 일반파일이 symlink로 교체돼도 `isFile()=false`→read 없이 `modified`(backup 있으면 restorable). 누적 byte cap·hash 재계산은 regular 경로 유지. +- **`restoreIgnoredBaseline`(쓰기 측)**: + - `clearNonDirAncestors`: `statSync(cur).isDirectory()`→`lstatSync`. 제거 조건을 **`!isDirectory() || isSymbolicLink()`로 명시**(Codex #2.1 — junction은 lstat isDirectory=false라 기능상 이미 잡히나 symlink 조건을 명시해 의도 고정). 첫 해당 조상 제거 후 return → `mkdirSync(recursive)`가 root 안에 체인 재생성. (단일 제거+재생성은 다중 링크 조상에도 안전 — 재생성 후 링크 조상 잔존 없음.) + - leaf write: `statSync(abs)`→`lstatSync`. symlink leaf는 `isFile()=false`→`rmSync`(링크 자체)→`writeFileSync`가 root 안 실파일 생성. 링크 통한 밖 쓰기 차단. + - created 엔트리 삭제(현 `rmSync(resolve(root,path),{recursive:true,force:true})` 직행): **helper 경유**(Codex #3) — `isLinkSync`로 링크면 recursive 없는 unlink 경로로 명시 처리(POSIX 실측상 recursive도 링크만 제거하나, 보안 의도·플랫폼 차이 테스트 고정·향후 refactor 의심 제거). + +`skipped` reason union에 `'symlink'` 추가(`'over-cap'|'read-failed'|'not-regular'|'symlink'`). + +### 2. `workspace-tools.ts` 통일 + +인라인 `resolveWithin`(부정확 `startsWith('..')`·JS realpath) 제거 → path-guard `resolveWithin` import. `walk`(이미 Dirent로 링크 skip) 유지. `read_file.classify`의 직접 `realpathSync(...)` 민감도 승격도 helper 경유로 정합(Codex #3.classify). + +## 표면화 & 정책 (A/B1 계승 · leak-zero) + +- 탐지된 링크는 기존 `skipped`/`unrestorable` + `workspace.ignored_changes` store 이벤트로 **경로(workspace-상대) + reason/kind만**. **링크 target(밖 경로)·파일 내용은 절대 노출 안 함.** +- sensitive-명 링크·`suspicious`(exotic reparse) = fail-closed. UX 문구는 target 비노출 선에서 "알 수 없는 링크/재분석 지점이라 안전상 처리하지 않음"(Codex #6). +- **root 자체가 symlink인 워크스페이스**: `realpathSync.native(root)` 기준으로 containment 판정(root realpath를 정준 기준으로 고정). 문서·테스트로 고정(Codex #8.4). + +## 문서화 (TOCTOU + 격리 한계 — advisory 정직성) + +스펙 §+코드 주석 양쪽(Codex #8.5): "경로검사 ≠ 격리". lstat-refusal은 *Fleet 자체 정상 FS 경로*가 링크를 안 따라가게 줄이는 advisory guard일 뿐, 스폰된 CLI의 직접 쓰기는 못 막고, leaf lstat→write 사이 TOCTOU 창이 남는다(순수 Node openat2/O_NOFOLLOW 크로스플랫폼 부재). **강한 격리 = OS/CLI 샌드박스(B-tier 향후)**: Codex 편집은 이미 `-s workspace-write`(registry.ts)로 OS 쓰기 경계 부분 적용 = B2(Fleet 내부 FS 층)와 별개 층이라 중복 아님; Claude(`acceptEdits`)·Gemini(`auto_edit`)는 권한 레이어일 뿐 + Claude sandbox는 native Windows 미지원(WSL2). + +## 테스트 + +- **path-guard 단위 먼저(TDD, Codex #8.1)**: `..foo` 정상 허용 / `../x` 거부 / 존재 symlink 조상 아래 미존재 tail 거부 / cross-drive·UNC 거부 / win32 case-fold / junction fixture(가능 시) / `lstatSync` EINVAL·UNKNOWN mock→`suspicious`. +- **ignored-baseline**: walk junction 비재귀(win32)·symlink 비재귀(POSIX) / capture 링크 비-read(sensitive throw·else skip) / collect 일반→symlink 교체 modified / restore junction·symlink 조상→밖 쓰기 없음·root 안 복원 / symlink leaf→링크 제거 후 root 안 write / created 링크 unlink(밖 내용 보존). +- junction 생성 admin 불요 → win32 vitest(`win32 보안 회귀`) required check 실 fixture. POSIX symlink는 POSIX CI. fakeGit + 실 FS fixture(B1 패턴 계승). +- 5게이트(`typecheck·lint·format:check·test·build`) + `npm run brain` + leak-zero 불변식. + +## 비목표 + +- OS/CLI 샌드박스 신규 배선(향후 B-tier·문서화). +- denylist 내부 sensitive(B1 확정 비목표). +- TOCTOU 완전차단(순수 Node 불가·문서화). +- 적대자 결정적 보장 · 순차→worktree 전환. +- **finding5(`workspace.ignored_discarded` live emit)** — Codex #5 권장대로 **별도 PR로 분리**(B2는 보안 link-guard 핵심; live emit은 UX 표면화·`OrchestratorEventType` union 확장 별건). + +## 영향 파일 + +`src/main/core/workspace/path-guard.ts`(신규+test) · `ignored-baseline.ts`(+test) · `workspace-tools.ts`(+test) · (필요 시 `git.ts` `samePath`를 helper로 흡수 검토). 신규 win32/POSIX 분기 테스트. From 7d6bc76a91a5d76246fb01dcb1cd826a61b05e29 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 14:43:19 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat(#128-B2):=20path-guard=20isLinkSync?= =?UTF-8?q?=20=E2=80=94=20lstat=20=EA=B8=B0=EB=B0=98=20=EB=A7=81=ED=81=AC/?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=ED=8C=90=EC=A0=95(junction=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- src/main/core/workspace/path-guard.test.ts | 66 ++++++++++++++++++++++ src/main/core/workspace/path-guard.ts | 23 ++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/main/core/workspace/path-guard.test.ts create mode 100644 src/main/core/workspace/path-guard.ts diff --git a/src/main/core/workspace/path-guard.test.ts b/src/main/core/workspace/path-guard.test.ts new file mode 100644 index 0000000..1cc167c --- /dev/null +++ b/src/main/core/workspace/path-guard.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as fs from 'node:fs' +import { isLinkSync } from './path-guard' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'fleet-pg-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('isLinkSync', () => { + it('일반 파일 → regular', () => { + writeFileSync(join(root, 'a.txt'), 'x') + expect(isLinkSync(join(root, 'a.txt'))).toBe('regular') + }) + it('디렉터리 → dir', () => { + mkdirSync(join(root, 'd')) + expect(isLinkSync(join(root, 'd'))).toBe('dir') + }) + it('미존재 → missing', () => { + expect(isLinkSync(join(root, 'nope'))).toBe('missing') + }) + // [#128-B2] best-effort 테스트 — vitest ESM 환경에서 node: 빌트인 named export 는 + // non-configurable(Object.defineProperty 금지)이라 vi.spyOn(fs, 'lstatSync') 가 + // "Cannot redefine property" 로 실패할 수 있다. + // namespace import(`import * as fs`)는 spy 시도를 위해 의도적으로 유지한다(B1 m4 교훈). + // spy 성공 환경에서는 완전히 검증되고, 실패 환경에서는 skip. + it('lstat EINVAL/UNKNOWN(exotic reparse) → suspicious(fail-closed)', () => { + const err = Object.assign(new Error('einval'), { code: 'EINVAL' }) + let spy: ReturnType + try { + spy = vi.spyOn(fs, 'lstatSync').mockImplementation(() => { + throw err + }) + } catch { + // ESM non-configurable: spy 미동작 — best-effort skip + return + } + try { + expect(isLinkSync(join(root, 'whatever'))).toBe('suspicious') + } finally { + spy.mockRestore() + } + }) +}) + +describe.skipIf(process.platform === 'win32')('isLinkSync (POSIX symlink)', () => { + it('symlink → link', () => { + writeFileSync(join(root, 'target.txt'), 'x') + symlinkSync(join(root, 'target.txt'), join(root, 'link.txt')) + expect(isLinkSync(join(root, 'link.txt'))).toBe('link') + }) +}) + +describe.skipIf(process.platform !== 'win32')('isLinkSync (Windows junction)', () => { + it('junction → link', () => { + mkdirSync(join(root, 'realdir')) + symlinkSync(join(root, 'realdir'), join(root, 'jdir'), 'junction') + expect(isLinkSync(join(root, 'jdir'))).toBe('link') + }) +}) diff --git a/src/main/core/workspace/path-guard.ts b/src/main/core/workspace/path-guard.ts new file mode 100644 index 0000000..98be5bf --- /dev/null +++ b/src/main/core/workspace/path-guard.ts @@ -0,0 +1,23 @@ +// namespace import — 테스트가 vi.spyOn(fs,'lstatSync')로 가로챌 수 있게 한다(ESM named-import +// 바인딩은 스파이가 가로채지 못함 — B1 m4 교훈). +import * as fs from 'node:fs' + +/** 경로의 종류를 lstat(링크 비추종)으로 판정한다. + * 'link' = POSIX symlink 또는 Windows junction(둘 다 lstat.isSymbolicLink()=true, 실증). + * 'suspicious' = FIFO/socket/device 등 비정형, 또는 lstat 가 EINVAL/UNKNOWN throw + * (OneDrive/AppExecLink 등 exotic reparse) — 안전상 따라가지 않음(fail-closed). + * 'missing' = ENOENT. */ +export type LinkKind = 'regular' | 'dir' | 'link' | 'suspicious' | 'missing' + +export function isLinkSync(abs: string): LinkKind { + try { + const st = fs.lstatSync(abs) + if (st.isSymbolicLink()) return 'link' + if (st.isDirectory()) return 'dir' + if (st.isFile()) return 'regular' + return 'suspicious' + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return 'missing' + return 'suspicious' + } +} From 2f40bf6cd689b8e56ca7ce85f1c89d1ab9354ee1 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 14:51:01 +0900 Subject: [PATCH 03/19] =?UTF-8?q?test(#128-B2):=20path-guard=20'suspicious?= =?UTF-8?q?'=20=EB=B6=84=EA=B8=B0=20FIFO=20=EC=8B=A4-=EC=BB=A4=EB=B2=84=20?= =?UTF-8?q?+=20spy=20=ED=83=80=EC=9D=B4=ED=95=91/=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- src/main/core/workspace/path-guard.test.ts | 11 +++++++++-- src/main/core/workspace/path-guard.ts | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/core/workspace/path-guard.test.ts b/src/main/core/workspace/path-guard.test.ts index 1cc167c..1bdff7c 100644 --- a/src/main/core/workspace/path-guard.test.ts +++ b/src/main/core/workspace/path-guard.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,7 +33,7 @@ describe('isLinkSync', () => { // spy 성공 환경에서는 완전히 검증되고, 실패 환경에서는 skip. it('lstat EINVAL/UNKNOWN(exotic reparse) → suspicious(fail-closed)', () => { const err = Object.assign(new Error('einval'), { code: 'EINVAL' }) - let spy: ReturnType + let spy: ReturnType | undefined try { spy = vi.spyOn(fs, 'lstatSync').mockImplementation(() => { throw err @@ -44,9 +45,15 @@ describe('isLinkSync', () => { try { expect(isLinkSync(join(root, 'whatever'))).toBe('suspicious') } finally { - spy.mockRestore() + spy?.mockRestore() } }) + it('FIFO 등 비정형(non-regular)은 suspicious', () => { + if (process.platform === 'win32') return // mkfifo 불가 + const p = join(root, 'pipe.dat') + execFileSync('mkfifo', [p]) + expect(isLinkSync(p)).toBe('suspicious') + }) }) describe.skipIf(process.platform === 'win32')('isLinkSync (POSIX symlink)', () => { diff --git a/src/main/core/workspace/path-guard.ts b/src/main/core/workspace/path-guard.ts index 98be5bf..dbb63c4 100644 --- a/src/main/core/workspace/path-guard.ts +++ b/src/main/core/workspace/path-guard.ts @@ -1,5 +1,8 @@ -// namespace import — 테스트가 vi.spyOn(fs,'lstatSync')로 가로챌 수 있게 한다(ESM named-import -// 바인딩은 스파이가 가로채지 못함 — B1 m4 교훈). +// namespace import — spy 가로채기가 환경에서 허용될 때 vi.spyOn(fs,'lstatSync')가 동작하도록 +// 한다(ESM named-import 바인딩은 스파이가 가로채지 못함 — B1 m4 교훈). +// Windows ESM 환경에서는 Node 빌트인 프로퍼티가 non-configurable이라 spy가 "Cannot redefine +// property"로 실패한다 — 따라서 EINVAL spy 테스트는 best-effort 이다. +// 'suspicious' 분기의 실 커버리지는 POSIX 환경에서 FIFO를 생성하는 테스트가 담당한다. import * as fs from 'node:fs' /** 경로의 종류를 lstat(링크 비추종)으로 판정한다. From ebcb92feae53d31d745452c2b63fbef27b01e8bb Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 14:55:47 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat(#128-B2):=20path-guard=20resolveWith?= =?UTF-8?q?in=20=E2=80=94=20realpath.native=20containment(=EB=AF=B8?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=20tail=C2=B7case-fold=C2=B7=EC=A0=95?= =?UTF-8?q?=ED=99=95=20predicate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- src/main/core/workspace/path-guard.test.ts | 47 +++++++++++++++++++++- src/main/core/workspace/path-guard.ts | 38 +++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/main/core/workspace/path-guard.test.ts b/src/main/core/workspace/path-guard.test.ts index 1bdff7c..8b5b98d 100644 --- a/src/main/core/workspace/path-guard.test.ts +++ b/src/main/core/workspace/path-guard.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as fs from 'node:fs' -import { isLinkSync } from './path-guard' +import { isLinkSync, resolveWithin } from './path-guard' let root: string beforeEach(() => { @@ -71,3 +71,48 @@ describe.skipIf(process.platform !== 'win32')('isLinkSync (Windows junction)', ( expect(isLinkSync(join(root, 'jdir'))).toBe('link') }) }) + +describe('resolveWithin', () => { + it('root 내부 파일 허용 + 정준 경로 반환', () => { + writeFileSync(join(root, 'a.txt'), 'x') + const out = resolveWithin(root, 'a.txt') + expect(out.toLowerCase()).toContain('a.txt') + }) + it("'..foo' 같은 정상 in-root 이름을 오거부하지 않는다", () => { + writeFileSync(join(root, '..foo'), 'x') + expect(() => resolveWithin(root, '..foo')).not.toThrow() + }) + it("'../x' 상위 탈출은 거부", () => { + expect(() => resolveWithin(root, join('..', 'x'))).toThrow(/워크스페이스 밖/) + }) + it('미존재 leaf 도 컨테인먼트만 검사(허용)', () => { + expect(() => resolveWithin(root, 'sub/new.txt')).not.toThrow() + }) + it('root 자체(빈 상대) 허용', () => { + expect(() => resolveWithin(root, '.')).not.toThrow() + }) +}) + +describe.skipIf(process.platform === 'win32')('resolveWithin (POSIX symlink ancestor)', () => { + it('존재하는 symlink 조상 아래 미존재 tail 은 밖이면 거부', () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + symlinkSync(outside, join(root, 'esc'), 'dir') + expect(() => resolveWithin(root, 'esc/whatever.txt')).toThrow(/워크스페이스 밖/) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform !== 'win32')('resolveWithin (Windows junction ancestor)', () => { + it('junction 조상 아래 미존재 tail 은 밖이면 거부', () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + symlinkSync(outside, join(root, 'esc'), 'junction') + expect(() => resolveWithin(root, 'esc/whatever.txt')).toThrow(/워크스페이스 밖/) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/core/workspace/path-guard.ts b/src/main/core/workspace/path-guard.ts index dbb63c4..0a1496a 100644 --- a/src/main/core/workspace/path-guard.ts +++ b/src/main/core/workspace/path-guard.ts @@ -4,6 +4,7 @@ // property"로 실패한다 — 따라서 EINVAL spy 테스트는 best-effort 이다. // 'suspicious' 분기의 실 커버리지는 POSIX 환경에서 FIFO를 생성하는 테스트가 담당한다. import * as fs from 'node:fs' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' /** 경로의 종류를 lstat(링크 비추종)으로 판정한다. * 'link' = POSIX symlink 또는 Windows junction(둘 다 lstat.isSymbolicLink()=true, 실증). @@ -24,3 +25,40 @@ export function isLinkSync(abs: string): LinkKind { return 'suspicious' } } + +// win32 비교는 case-insensitive(NTFS) — 양변 case-fold. +const fold = (p: string): string => (process.platform === 'win32' ? p.toLowerCase() : p) + +/** root realpath 기준으로 p 를 정준 절대경로로 해소하고 root 내부인지 검사한다. + * lexical 비교는 symlink 비해소라 무력 → realpath 필수. 미존재 leaf 는 + * "최근접 존재 조상 realpath + 미존재 tail 재부착"으로 symlink 조상 탈출도 잡는다. + * realpath 실패(exotic reparse/UNC) 또는 root 밖 → throw(fail-closed). */ +export function resolveWithin(root: string, p: string): string { + let realRoot: string + try { + realRoot = fs.realpathSync.native(root) + } catch (err) { + throw new Error(`워크스페이스 realpath 해소 불가(운영 에러): ${root}`, { cause: err }) + } + const abs = resolve(realRoot, p) + // 최근접 존재 조상까지 올라가 그 조상의 realpath 를 구하고 미존재 tail 을 재부착한다. + let existingAbs = abs + const tail: string[] = [] + while (!fs.existsSync(existingAbs)) { + tail.unshift(basename(existingAbs)) + const parent = dirname(existingAbs) + if (parent === existingAbs) break + existingAbs = parent + } + let realCandidate: string + try { + const realExisting = fs.realpathSync.native(existingAbs) + realCandidate = tail.length ? resolve(realExisting, ...tail) : realExisting + } catch (err) { + throw new Error(`경로 realpath 해소 실패(안전상 거부): ${p}`, { cause: err }) + } + const rel = relative(fold(realRoot), fold(realCandidate)) + const inside = rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) + if (!inside) throw new Error(`경로가 워크스페이스 밖입니다: ${p}`) + return realCandidate +} From 9854adb9bc195f157323b4246e4d88f91eb96965 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:03:03 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat(#128-B2):=20listIgnored=20walk/?= =?UTF-8?q?=EC=97=94=ED=8A=B8=EB=A6=AC=20link-aware=20=E2=80=94=20symlink/?= =?UTF-8?q?junction=20=EC=9E=AC=EA=B7=80=C2=B7=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 32 +++++++++++++ src/main/core/workspace/ignored-baseline.ts | 48 ++++++++++++------- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 8488732..20c052a 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -8,6 +8,7 @@ import { readFileSync as readFile, rmSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -541,6 +542,37 @@ describe('real-git: revert→restore seam (invariant verification)', () => { }) }) +describe.skipIf(process.platform === 'win32')('[#128-B2] symlink 비추종 (POSIX)', () => { + it('git-보고 ignored 가 symlink-to-dir 면 재귀 안 하고 밖을 수집 안 한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'secret.txt'), 'SECRET') + symlinkSync(outside, join(root, 'link'), 'dir') + const git = fakeGitIgnored(['link/']) // git 이 디렉터리처럼 보고 + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + // 밖의 secret.txt 가 절대 entries 에 들어오면 안 됨 + expect([...base.entries.keys()].some((k) => k.includes('secret'))).toBe(false) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) + +describe.skipIf(process.platform !== 'win32')('[#128-B2] junction 비추종 (Windows)', () => { + it('git-보고 ignored 가 junction 이면 재귀 안 하고 밖을 수집 안 한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'secret.txt'), 'SECRET') + symlinkSync(outside, join(root, 'link'), 'junction') + const git = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect([...base.entries.keys()].some((k) => k.includes('secret'))).toBe(false) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) + // ── Codex 3차 반영: [:96] [:109] [:244] [:270] ── describe('[:96] unreadable dir → fail-closed (walk readdirSync catch)', () => { diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index a2afb45..29a2355 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -13,6 +13,7 @@ import { import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { SENSITIVE_FILE } from '../safety/approval' import type { GitRunner } from './git' +import { isLinkSync } from './path-guard' /** [:270] walk의 unreadable-dir 및 listIgnored의 over-cap 합성 마커 경로 상수. * restore/rmSync 등 실-경로로 취급하는 곳에서는 반드시 이 값을 걸러내야 한다. */ @@ -46,7 +47,7 @@ export interface IgnoredEntry { } export interface IgnoredBaseline { entries: Map - skipped: { path: string; reason: 'over-cap' | 'read-failed' | 'not-regular' }[] + skipped: { path: string; reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' }[] } // git status --ignored 로 in-scope ignored 파일을 열거한다. @@ -62,14 +63,14 @@ async function listIgnored( root: string, git: GitRunner, policy: ScanPolicy, -): Promise<{ files: string[]; skipped: { path: string; reason: 'over-cap' }[] }> { +): Promise<{ files: string[]; skipped: { path: string; reason: 'over-cap' | 'symlink' }[] }> { const r = await git.run(['status', '--ignored', '--porcelain=v1', '-z'], root) // [P2-3] git status 실패는 hard-fail if (r.code !== 0) throw new Error('git status --ignored 실패: ' + r.stderr.trim()) const records = r.stdout.split('\0').filter(Boolean) const ignored = records.filter((rec) => rec.startsWith('!! ')).map((rec) => rec.slice(3)) const files: string[] = [] - const skipped: { path: string; reason: 'over-cap' }[] = [] + const skipped: { path: string; reason: 'over-cap' | 'symlink' }[] = [] let generalCount = 0 // capped: generalCount >= maxFiles に達した後は true。walk は冒頭で確認し即 return する。 let capped = false @@ -93,9 +94,9 @@ async function listIgnored( const walk = (relDir: string): void => { // early-terminate: cap 도달 후 서브디렉터리 탐색을 중단(unbounded traversal 방지) if (capped) return - let names: string[] + let entries: import('node:fs').Dirent[] try { - names = readdirSync(resolve(root, relDir)) + entries = readdirSync(resolve(root, relDir), { withFileTypes: true }) } catch { // [:96] unreadable dir → fail-closed: 조용히 skip하지 않고 over-cap escalation 기록. // permission error 등으로 내부를 읽지 못하면 sensitive 파일이 숨을 수 있으므로 @@ -106,26 +107,26 @@ async function listIgnored( } return } - for (const name of names) { + for (const ent of entries) { if (capped) return - const rel = `${relDir}/${name}` - let st - try { - st = statSync(resolve(root, rel)) - } catch { - if (policy.sensitiveRe.test(rel)) pushFile(rel) + const rel = `${relDir}/${ent.name}` + if (ent.isSymbolicLink()) { + // [#128-B2] symlink/junction 은 따라가지 않는다 — 밖을 가리켜 읽거나 재귀하지 않음. + skipped.push({ path: rel.replace(/\\/g, '/'), reason: 'symlink' }) continue } - if (st.isDirectory()) { + if (ent.isDirectory()) { // [:109] 중첩 denylist 디렉터리는 top-level 과 동일하게 skip(내부 탐색 금지). // 예: packages/x/node_modules/ — denylist 확인 없이 walk 하면 cap 낭비. // [#128-C] 내부 sensitive 커버는 B1 확정 비목표 — evasion 방어는 B2 프로세스 격리로 이관(#128 잔여). const relSlash = `${rel}/` if (policy.denylistRe.test(rel) || policy.denylistRe.test(relSlash)) continue walk(rel) - } else { - pushFile(rel) + continue } + // [#128-B2] 파일 + 비정형(FIFO/socket/device) → pushFile. capture/collect 의 lstat 가 + // regular 면 백업, 아니면 'not-regular' 로 표면화한다(B1 동작 보존 — silent drop 금지). + pushFile(rel) } } for (const e of ignored) { @@ -136,8 +137,18 @@ async function listIgnored( // [#128-C] denylist 디렉터리 내부는 스캔하지 않음(비용 경계). 내부 sensitive 커버는 B1 확정 비목표 — // evasion(숨긴 위치 쓰기) 방어는 경로검사가 아닌 B2 프로세스 격리로 이관(#128 잔여). if (policy.denylistRe.test(`${dir}/`)) continue + // [#128-B2] git 이 디렉터리로 보고해도 실제 junction/symlink 면 재귀 금지. + if (isLinkSync(resolve(root, dir)) === 'link') { + skipped.push({ path: dir, reason: 'symlink' }) + continue + } walk(dir) } else { + // [#128-B2] 파일로 보고된 엔트리가 실제 링크면 수집 안 함(capture 의 lstat 가 이중 방어). + if (isLinkSync(resolve(root, rel)) === 'link') { + skipped.push({ path: rel, reason: 'symlink' }) + continue + } pushFile(rel) } } @@ -151,9 +162,10 @@ export async function captureIgnoredBaseline( ): Promise { const { files, skipped: enumSkipped } = await listIgnored(root, git, policy) const entries = new Map() - const skipped: { path: string; reason: 'over-cap' | 'read-failed' | 'not-regular' }[] = [ - ...enumSkipped, - ] + const skipped: { + path: string + reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' + }[] = [...enumSkipped] let totalBytes = 0 try { for (const path of files) { From 2280033dde4012d46053d1d89905b064706b071e Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:09:17 +0900 Subject: [PATCH 06/19] =?UTF-8?q?test(#128-B2):=20listIgnored=20=EC=83=88?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20skipped{symlink}=20?= =?UTF-8?q?=EB=A9=94=EC=BB=A4=EB=8B=88=EC=A6=98=20=EB=8B=A8=EC=96=B8=20+?= =?UTF-8?q?=20suspicious=20fallthrough=20=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 새 테스트(symlink POSIX / junction Windows)에 `expect(base.skipped).toContainEqual({path:'link',reason:'symlink'})` 추가 — side-effect 뿐 아니라 보안 메커니즘 자체를 단언. ignored-baseline.ts top-level 링크 분기에 'suspicious'/'missing' 은 pushFile로 낙하해 capture lstat(Task 4)이 not-regular 로 표면화한다는 주석 추가. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- src/main/core/workspace/ignored-baseline.test.ts | 4 ++++ src/main/core/workspace/ignored-baseline.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 20c052a..8e4af09 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -552,6 +552,8 @@ describe.skipIf(process.platform === 'win32')('[#128-B2] symlink 비추종 (POSI const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) // 밖의 secret.txt 가 절대 entries 에 들어오면 안 됨 expect([...base.entries.keys()].some((k) => k.includes('secret'))).toBe(false) + // 보안 메커니즘 단언: 링크가 실제로 symlink 이유로 skipped 에 기록되어야 함 + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) } finally { rmSync(outside, { recursive: true, force: true }) } @@ -567,6 +569,8 @@ describe.skipIf(process.platform !== 'win32')('[#128-B2] junction 비추종 (Win const git = fakeGitIgnored(['link/']) const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) expect([...base.entries.keys()].some((k) => k.includes('secret'))).toBe(false) + // 보안 메커니즘 단언: junction 이 실제로 symlink 이유로 skipped 에 기록되어야 함 + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) } finally { rmSync(outside, { recursive: true, force: true }) } diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 29a2355..e371d4f 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -138,6 +138,8 @@ async function listIgnored( // evasion(숨긴 위치 쓰기) 방어는 경로검사가 아닌 B2 프로세스 격리로 이관(#128 잔여). if (policy.denylistRe.test(`${dir}/`)) continue // [#128-B2] git 이 디렉터리로 보고해도 실제 junction/symlink 면 재귀 금지. + // 'suspicious'/'missing' 은 이 분기에 걸리지 않아 pushFile/missing 경로로 떨어지며, + // capture 의 lstat 이 non-regular 로 표면화한다(Task 4 동작 — 여기선 의도적 미처리). if (isLinkSync(resolve(root, dir)) === 'link') { skipped.push({ path: dir, reason: 'symlink' }) continue From f86de80858ea0e40af1b1d2041d63cc6ee2c51cb Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:15:21 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat(#128-B2):=20captureIgnoredBaseline?= =?UTF-8?q?=20lstat=20=E2=80=94=20=EB=A7=81=ED=81=AC=20leaf=20read=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8(=EB=B0=96=20=EB=B9=84=EB=B0=80=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 29 +++++++++++++++++++ src/main/core/workspace/ignored-baseline.ts | 9 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 8e4af09..45baddc 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -701,3 +701,32 @@ describe('[:270] scan-capped marker restore exclusion', () => { expect(res).toEqual({ capped: false }) }) }) + +describe.skipIf(process.platform === 'win32')('[#128-B2] capture 링크 leaf (POSIX)', () => { + it('비-sensitive symlink ignored 파일은 read 없이 symlink 로 skip', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'secret.txt'), 'SECRET') + symlinkSync(join(outside, 'secret.txt'), join(root, 'link.dat')) + const git = fakeGitIgnored(['link.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect(base.entries.has('link.dat')).toBe(false) + expect(base.skipped).toContainEqual({ path: 'link.dat', reason: 'symlink' }) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + it('sensitive-명 symlink 는 throw(fail-closed)', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'k'), 'KEY') + symlinkSync(join(outside, 'k'), join(root, '.env')) + const git = fakeGitIgnored(['.env']) + await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( + /링크|일반 파일이 아님/, + ) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index e371d4f..8405bdb 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { chmodSync, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, @@ -175,12 +176,18 @@ export async function captureIgnoredBaseline( const abs = resolve(root, path) let st try { - st = statSync(abs) + st = lstatSync(abs) // [#128-B2] 링크 비추종 — 링크면 isFile()=false 로 아래 분기 적중 } catch (err) { if (sensitive) throw new Error(`민감 ignored 파일 stat 실패: ${path}`, { cause: err }) skipped.push({ path, reason: 'read-failed' }) continue } + // [#128-B2] symlink/junction → read 금지(밖 target 유출 차단). sensitive 면 fail-closed. + if (st.isSymbolicLink()) { + if (sensitive) throw new Error(`민감 ignored 파일이 링크임(백업 불가): ${path}`) + skipped.push({ path, reason: 'symlink' }) + continue + } // [#128-A] non-regular(FIFO/socket/device/dir)면 readFileSync 가 hang/오류 → read 전 차단. if (!st.isFile()) { if (sensitive) throw new Error(`민감 ignored 파일이 일반 파일이 아님(백업 불가): ${path}`) From ee95ce21a02aeb6e066ab33c1a94de006a5453e2 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:22:50 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat(#128-B2):=20collectIgnoredChanges=20?= =?UTF-8?q?lstat=20=E2=80=94=20=EB=A7=81=ED=81=AC=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=EB=90=9C=20baseline=20=ED=8C=8C=EC=9D=BC=20=EB=B9=84-?= =?UTF-8?q?read=20modified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 21 +++++++++++++++++++ src/main/core/workspace/ignored-baseline.ts | 11 ++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 45baddc..992b533 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -702,6 +702,27 @@ describe('[:270] scan-capped marker restore exclusion', () => { }) }) +describe.skipIf(process.platform === 'win32')('[#128-B2] collect 링크 교체 (POSIX)', () => { + // 깨끗한 distinguisher: symlink target 내용을 baseline 과 *동일*하게 둔다. + // - old(statSync 추종): target('orig')을 읽어 hash 가 baseline 과 일치 → '변경 없음' 오판(보안 구멍). + // - new(lstat 비추종): isSymbolicLink → modified. (단순히 다른 내용을 쓰면 old 도 modified 라 거짓-green.) + it('baseline 파일이 같은 내용 가리키는 symlink 로 교체돼도 modified 로 잡는다(링크 비추종)', async () => { + writeFileSync(join(root, 'f.dat'), 'orig') + const git = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'same'), 'orig') // target 내용 == baseline + rmSync(join(root, 'f.dat')) + symlinkSync(join(outside, 'same'), join(root, 'f.dat')) + const cs = await collectIgnoredChanges(root, git, base, DEFAULT_IGNORED_POLICY) + expect(cs.changes).toContainEqual({ path: 'f.dat', change: 'modified', sensitive: false }) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) +}) + describe.skipIf(process.platform === 'win32')('[#128-B2] capture 링크 leaf (POSIX)', () => { it('비-sensitive symlink ignored 파일은 read 없이 symlink 로 skip', async () => { const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 8405bdb..afe4437 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -274,15 +274,22 @@ export async function collectIgnoredChanges( if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) continue } - // [:213] size-guard + [#128-A] non-regular 가드: read 전 stat 으로 종류·크기 확인 + // [:213] size-guard + [#128-A] non-regular 가드 + [#128-B2] 링크 비추종: read 전 lstat 으로 종류·크기 확인 let st try { - st = statSync(abs) + st = lstatSync(abs) // [#128-B2] 링크 비추종 } catch { changes.push({ path, change: 'modified', sensitive: entry.sensitive }) unrestorable.push({ path, reason: 'stat-failed' }) continue } + if (st.isSymbolicLink()) { + // [#128-B2] baseline 일반파일이 링크로 교체됨 = modified. read 안 함(밖 유출 차단). + // backup 있으면 restore 가 링크 제거 후 복원 → unrestorable 아님. + changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) + continue + } if (!st.isFile()) { // baseline 일반 파일이 non-regular 로 교체됨 = modified. read 없이(hang 방지). // backup 있으면 restore 가 비-일반 leaf 제거 후 복원 → unrestorable 아님. From 6eae59b48cd8708715baa60bdb048c5c793462a3 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:31:34 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat(#128-B2):=20restore=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=B8=A1=20link-aware=20=E2=80=94=20=EC=A1=B0?= =?UTF-8?q?=EC=83=81/leaf/created=20=EB=A7=81=ED=81=AC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EB=B0=96=20=EC=93=B0=EA=B8=B0=C2=B7=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 103 ++++++++++++++++++ src/main/core/workspace/ignored-baseline.ts | 29 ++++- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 992b533..2815808 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -751,3 +751,106 @@ describe.skipIf(process.platform === 'win32')('[#128-B2] capture 링크 leaf (PO } }) }) + +describe.skipIf(process.platform === 'win32')( + '[#128-B2] restore 쓰기 측 link-guard (POSIX)', + () => { + it('symlink leaf 는 링크를 제거하고 root 안 실파일로 복원(밖 target 미오염)', async () => { + writeFileSync(join(root, 'f.dat'), 'orig') + const git = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'victim'), 'DO-NOT-OVERWRITE') + rmSync(join(root, 'f.dat')) + symlinkSync(join(outside, 'victim'), join(root, 'f.dat')) + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + // 밖 victim 은 그대로, root/f.dat 은 backup('orig')으로 복원 + expect(readFile(join(outside, 'victim')).toString()).toBe('DO-NOT-OVERWRITE') + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + it('created symlink-to-dir 는 링크만 unlink(밖 디렉터리 내용 보존)', async () => { + const git = fakeGitIgnored(['f.dat', 'esc']) // f.dat=baseline, esc=created link + writeFileSync(join(root, 'f.dat'), 'orig') + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'keep'), 'KEEP') + symlinkSync(outside, join(root, 'esc'), 'dir') // 실행 중 생성된 링크 + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + expect(existsSync(join(root, 'esc'))).toBe(false) // 링크 제거됨 + expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') // 밖 내용 보존 + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + + it('실행 중 새로 생긴 escaping symlink 는 rollback 에서 unlink(밖 내용 보존)', async () => { + writeFileSync(join(root, 'f.dat'), 'orig') + const baseGit = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'keep'), 'KEEP') + symlinkSync(outside, join(root, 'newlink'), 'dir') // 에이전트가 새로 만든 링크 + const git = fakeGitIgnored(['f.dat', 'newlink']) // restore 시점 git 보고 + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + expect(existsSync(join(root, 'newlink'))).toBe(false) + expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + +describe.skipIf(process.platform !== 'win32')( + '[#128-B2] restore 쓰기 측 junction-guard (Windows)', + () => { + it('created JUNCTION-to-dir 는 junction 만 unlink(밖 디렉터리 내용 보존)', async () => { + // capture 시점에 esc 는 없음 — baseline 에 미등재 + const baseGit = fakeGitIgnored(['f.dat']) + writeFileSync(join(root, 'f.dat'), 'orig') + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'keep'), 'KEEP') + symlinkSync(outside, join(root, 'esc'), 'junction') // 에이전트가 실행 중 만든 junction + // restore 시점 git 은 esc 를 보고(실행 중 생성된 junction) + const curGit = fakeGitIgnored(['f.dat', 'esc']) + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) + expect(existsSync(join(root, 'esc'))).toBe(false) // junction 제거됨 + expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') // 밖 내용 보존 + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + + it('junction 조상 → clearNonDirAncestors 제거 후 실파일 in-root 복원(밖 내용 보존)', async () => { + // baseline: a/b/c.txt + mkdirSync(join(root, 'a', 'b'), { recursive: true }) + writeFileSync(join(root, 'a', 'b', 'c.txt'), 'ORIG') + const git = fakeGitIgnored(['a/b/c.txt']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + writeFileSync(join(outside, 'victim'), 'OUTSIDE') + // 에이전트가 a/ 디렉터리 전체를 지우고 'a'를 outside 에 대한 junction 으로 교체 + rmSync(join(root, 'a'), { recursive: true, force: true }) + symlinkSync(outside, join(root, 'a'), 'junction') + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + // junction 이 제거되고 a/b/c.txt 가 in-root 에 복원됨 + const { statSync: st } = await import('node:fs') + expect(st(join(root, 'a')).isDirectory()).toBe(true) + expect(readFile(join(root, 'a', 'b', 'c.txt')).toString()).toBe('ORIG') + // 밖 victim 미오염 + expect(readFile(join(outside, 'victim')).toString()).toBe('OUTSIDE') + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index afe4437..8732f58 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -8,7 +8,6 @@ import { readdirSync, readFileSync, rmSync, - statSync, writeFileSync, } from 'node:fs' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' @@ -338,7 +337,15 @@ function clearNonDirAncestors(root: string, abs: string): void { let cur = root for (const part of relDir.split(/[\\/]/).filter(Boolean)) { cur = resolve(cur, part) - if (existsSync(cur) && !statSync(cur).isDirectory()) { + if (!existsSync(cur)) continue + let ls + try { + ls = lstatSync(cur) // [#128-B2] existsSync→lstat race 는 fail-safe(advisory) + } catch { + continue + } + // [#128-B2] 비-dir 또는 링크(junction 포함) 조상은 제거 → mkdirSync(recursive) 가 root 안 체인 재생성. + if (!ls.isDirectory() || ls.isSymbolicLink()) { rmSync(cur, { recursive: true, force: true }) return } @@ -356,17 +363,27 @@ export async function restoreIgnoredBaseline( // 이번 삭제 패스에서 누락될 수 있음 → rollback 불완전 가능성 표면화). const capped = skipped.some((s) => s.path === SCAN_CAPPED && s.reason === 'over-cap') const skippedPaths = new Set(baseline.skipped.map((s) => s.path)) + // [#128-B2] 링크-aware created 삭제 헬퍼. + // lexical containment(realpath 아님 — 탈출 링크도 unlink 해야 하므로 realpath 추종 금지). + const removeCreated = (rel: string): void => { + const abs = resolve(root, rel) + // git-상대 경로는 보통 root 아래지만, 이상한 절대/상위 경로 방어. + const r = relative(root, abs) + if (r === '..' || r.startsWith(`..${sep}`) || isAbsolute(r)) return + // 링크면 recursive 금지 — 링크 자체만 unlink(밖 내용 보존). isLinkSync=lstat(비추종). + rmSync(abs, isLinkSync(abs) === 'link' ? { force: true } : { recursive: true, force: true }) + } // 1) created(현재 in-scope, baseline·skipped 둘 다 없음) → 삭제. for (const path of files) { if (baseline.entries.has(path) || skippedPaths.has(path)) continue - rmSync(resolve(root, path), { recursive: true, force: true }) + removeCreated(path) } // [:229] over-cap skipped 중 baseline 에 없는 것도 삭제(에이전트가 cap 초과로 만든 파일 rollback). // [:270] SCAN_CAPPED 합성 마커는 실-경로가 아니므로 rmSync 대상에서 제외한다. for (const s of skipped) { if (s.path === SCAN_CAPPED) continue if (baseline.entries.has(s.path) || skippedPaths.has(s.path)) continue - rmSync(resolve(root, s.path), { recursive: true, force: true }) + removeCreated(s.path) } // 2) backup 보유 엔트리 → 백업에서 복원(modified·deleted 모두 포함). for (const [path, entry] of baseline.entries) { @@ -376,9 +393,9 @@ export async function restoreIgnoredBaseline( mkdirSync(dirname(abs), { recursive: true }) // [P1-b] if existing path is not a regular file (e.g. directory), remove it first if (existsSync(abs)) { - const st = statSync(abs) + const st = lstatSync(abs) // [#128-B2] 비추종 — symlink leaf 는 isFile()=false 로 제거 대상 if (!st.isFile()) { - rmSync(abs, { recursive: true, force: true }) + rmSync(abs, { recursive: true, force: true }) // 링크/디렉터리 제거 후 실파일로 복원 } } writeFileSync(abs, entry.backup) From 35d950f8cb952c481b1a34cbf69760021ef40629 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:44:23 +0900 Subject: [PATCH 10/19] =?UTF-8?q?refactor(#128-B2):=20workspace-tools=20co?= =?UTF-8?q?ntainment=EB=A5=BC=20path-guard=20helper=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC(startsWith=20=EC=98=A4=EA=B1=B0=EB=B6=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- src/main/core/tools/workspace-tools.test.ts | 12 ++++++++- src/main/core/tools/workspace-tools.ts | 29 ++++++--------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/main/core/tools/workspace-tools.test.ts b/src/main/core/tools/workspace-tools.test.ts index 1367642..5c3bae6 100644 --- a/src/main/core/tools/workspace-tools.test.ts +++ b/src/main/core/tools/workspace-tools.test.ts @@ -1,4 +1,5 @@ -import { promises as fs } from 'node:fs' +import { promises as fs, writeFileSync } from 'node:fs' +import { join } from 'node:path' import * as os from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -152,6 +153,15 @@ describe('createWorkspaceReadTools', () => { ) }) + it('[#128-B2] resolveWithin 통일 — "..foo" 정상 파일 읽기 가능(오거부 회귀 방지)', async () => { + // root 는 기존 테스트 헬퍼가 만든 임시 워크스페이스 사용 + writeFileSync(join(root, '..foo'), 'hello') + const tools = createWorkspaceReadTools(root) + const readFileTool = tools.find((t) => t.definition.name === 'read_file')! + const out = await readFileTool.execute({ path: '..foo' }, { signal: undefined } as never) + expect(out).toBe('hello') + }) + it('read_file 은 대형 파일을 전체 적재 없이 앞부분만 반환한다', async () => { await fs.writeFile(path.join(root, 'big.txt'), 'x'.repeat(300 * 1024)) const out = await pick(createWorkspaceReadTools(root), 'read_file').execute( diff --git a/src/main/core/tools/workspace-tools.ts b/src/main/core/tools/workspace-tools.ts index 3edd401..83b81fd 100644 --- a/src/main/core/tools/workspace-tools.ts +++ b/src/main/core/tools/workspace-tools.ts @@ -1,7 +1,8 @@ -import { promises as fs, realpathSync } from 'node:fs' +import { promises as fs } from 'node:fs' import * as path from 'node:path' import safe from 'safe-regex' import { SENSITIVE_FILE } from '../safety/approval' +import { resolveWithin } from '../workspace/path-guard' import type { FleetTool } from './types' const MAX_FILE_BYTES = 256 * 1024 @@ -23,21 +24,6 @@ export interface WorkspaceToolLimits { } type ResolvedLimits = Required -/** 입력 경로를 root 내부로 격리한다. realpath 로 심볼릭 링크 탈출까지 차단. 밖이면 throw. */ -async function resolveWithin(root: string, p: string): Promise { - const rootReal = await fs.realpath(root) - const abs = path.resolve(rootReal, p) - let real: string - try { - real = await fs.realpath(abs) // 존재하면 심볼릭 해소 - } catch { - real = abs // 미존재(곧 ENOENT) — 정규화 경로로 컨테인먼트만 검사 - } - const rel = path.relative(rootReal, real) - if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return real - throw new Error(`경로가 워크스페이스 밖입니다: ${p}`) -} - /** * root 하위 파일을 재귀 순회(스킵 디렉터리 제외). * opendir 스트리밍으로 디렉터리당 메모리를 O(1)로 바운드한다 — readdir 는 거대 디렉터리(수십만 엔트리) @@ -77,16 +63,17 @@ function readFileTool(root: string, lim: ResolvedLimits): FleetTool { // 심볼릭 링크가 민감 파일을 가리키면(원 인자는 무해해 보여도) 실제 대상 기준으로 destructive 로 승격한다. // (자동승인 우회 방지 — execute 의 realpath 해소와 위험도 분류를 일치시킨다.) try { - if (SENSITIVE_FILE.test(realpathSync(path.resolve(root, p)))) return 'destructive' + // resolveWithin 은 밖이면 throw → 분류 단계에선 무시(execute 가 처리). 안이면 정준 경로로 민감도 판정. + if (SENSITIVE_FILE.test(resolveWithin(root, p))) return 'destructive' } catch { - // 미존재 등은 무시 — execute 의 resolveWithin/stat 가 처리한다. + /* 미존재/밖 — execute 의 resolveWithin/stat 가 처리 */ } return 'safe' }, async execute(input) { const p = asStr((input as { path?: unknown })?.path) if (!p) throw new Error('read_file: path 인자가 필요합니다.') - const abs = await resolveWithin(root, p) + const abs = resolveWithin(root, p) const stat = await fs.stat(abs) if (!stat.isFile()) throw new Error(`read_file: 파일이 아닙니다: ${p}`) if (stat.size > lim.maxFileBytes) { @@ -125,7 +112,7 @@ function listDirectoryTool(root: string, lim: ResolvedLimits): FleetTool { classify: () => 'safe', async execute(input) { const p = asStr((input as { path?: unknown })?.path) ?? '.' - const abs = await resolveWithin(root, p) + const abs = resolveWithin(root, p) const entries = await fs.readdir(abs, { withFileTypes: true }) const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort() if (lines.length === 0) return '(빈 디렉터리)' @@ -178,7 +165,7 @@ function grepTool(root: string, lim: ResolvedLimits): FleetTool { ) } const rootReal = await fs.realpath(root) - const start = await resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.') + const start = resolveWithin(root, asStr((input as { path?: unknown })?.path) ?? '.') const out: string[] = [] let scanned = 0 let truncated = false From 52602c8fb2f235990e040d848d3042be0f3d5660 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:50:46 +0900 Subject: [PATCH 11/19] =?UTF-8?q?docs(#128-B2):=20TOCTOU/=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=20=ED=95=9C=EA=B3=84=20=EC=A3=BC=EC=84=9D(path-guard)?= =?UTF-8?q?=20+=20brain=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- brain.md | 22 ++++++++------- .../2026-06-23-issue128-B2-link-guard.md | 27 ++++++++++++------- src/main/core/workspace/path-guard.ts | 5 ++++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/brain.md b/brain.md index cae163f..cc3c884 100644 --- a/brain.md +++ b/brain.md @@ -1,7 +1,7 @@ # Fleet — 코드베이스 브레인 (자동 생성) > `npm run brain` 로 `src/` 에서 자동 추출한 구조 지도다. **코드를 탐색하기 전에 이 파일을 먼저 읽어** 토큰을 아껴라. -> 60 files · 151 import wires · 41 IPC channels · 생성 2026-06-22T16:40 UTC +> 61 files · 153 import wires · 41 IPC channels · 생성 2026-06-23T06:48 UTC > 표기: `파일 — 역할 · →의존 · ←피의존`. id 는 `main/core/` 생략(예: `session/manager`). ## 레이어 (위 → 아래로 흐름) @@ -82,7 +82,7 @@ ### orchestrator · core — 여러 AI에게 역할을 나눠주고, 목표를 작은 작업들로 쪼개 차례로 시키고, 서로 검토·수정·요약까지 마치도록 전체 흐름을 지휘하는 '작업 진행 본부' 모듈이다. - **orchestrator/orchestrator** — 목표 하나를 받아 계획·구현·검토·검증·요약까지 전 과정을 지휘하는 작업 총괄 지휘자 _목표를 작은 작업들로 쪼갠 뒤, 각 작업을 구현 AI가 실제 파일을 고치게 하고 다른 AI가 그 변경을 교차 검토해 통과할 때까지 반복하며, 위험한 변경은 승인을 받고 최종에는 테스트로 검증하고 실패하면 자동으로 고치게 합니다. 한 작업이 실패해도 전체가 멈추지 않게 격리하고, 사용자가 취소하면 진행 중 변경을 되돌리고 중단합니다._ - - →의존: orchestrator/assignment, orchestrator/diff-risk, orchestrator/ignored-guard, orchestrator/plan, orchestrator/review, safety/approval, session/manager, shared/types, store/types, workspace/git, +1 · ←피의존: engine · 939줄 + - →의존: orchestrator/assignment, orchestrator/diff-risk, orchestrator/ignored-guard, orchestrator/plan, orchestrator/review, safety/approval, session/manager, shared/types, store/types, workspace/git, +1 · ←피의존: engine · 953줄 - **orchestrator/diff-risk** — AI가 바꾼 코드가 위험한 변경인지 판정하는 위험 신호등 _AI가 고친 내용을 보고 비밀번호 같은 민감한 파일을 건드렸거나, 파일을 너무 많이 지웠거나, 변경 내용이 너무 길어 끝이 잘려 확인이 불가능하면 '위험'으로 표시하고 그 이유를 함께 알려 줍니다. 의심스러우면 안전하게 '위험' 쪽으로 분류합니다._ - →의존: safety/approval, shared/types, workspace/git, workspace/ignored-baseline · ←피의존: orchestrator/orchestrator · 37줄 - **orchestrator/plan** — 큰 목표를 실행 가능한 작은 작업 목록으로 쪼개 주는 계획 분해기 _기획 담당 AI에게 목표를 4~8개의 작업으로 나눠 달라고 요청하고, AI가 돌려준 응답이 형식이 조금 어긋나도 너그럽게 읽어내 작업 목록으로 정리합니다. 검증(테스트·빌드)이 실패하면 그 실패 내용을 다시 AI에게 알려 부족한 부분만 채울 '추가 보정 작업'도 뽑아냅니다._ @@ -90,7 +90,7 @@ - **orchestrator/assignment** — 어떤 AI에게 어떤 역할(기획·구현·검토 등)을 맡길지 정하는 자리 배정표 _'계획짜기·설계·구현·검토·테스트' 같은 7가지 역할을 정해진 규칙(수동 지정·차례로 돌리기·잘하는 사람 우선)에 따라 AI들에게 나눠 줍니다. 같은 입력이면 늘 같은 결과가 나오고, '잘하는 사람 우선' 규칙에서도 한 AI에게 일이 몰리지 않도록 적게 쓴 AI를 먼저 골라 균형을 맞춥니다._ - →의존: shared/types · ←피의존: engine, orchestrator/orchestrator, orchestrator/plan · 96줄 - **orchestrator/ignored-guard** - - →의존: workspace/git, workspace/ignored-baseline · ←피의존: orchestrator/orchestrator · 44줄 + - →의존: workspace/git, workspace/ignored-baseline · ←피의존: orchestrator/orchestrator · 50줄 - **orchestrator/review** — 각 단계에서 AI에게 보낼 지시문을 만들고 검토 결과를 읽어내는 대화 문구 담당 _구현·검토·요약·수정 단계마다 AI에게 보낼 안내 문구를 상황에 맞게 만들어 주고, 검토 AI의 답에서 '승인인지 수정 요청인지'와 그 피드백을 뽑아냅니다. AI가 형식을 안 지킨 어수선한 답을 줘도 핵심을 읽어내도록 대비책을 갖췄습니다._ - →의존: shared/types · ←피의존: orchestrator/orchestrator, orchestrator/plan · 121줄 @@ -128,12 +128,20 @@ - **tools/loop** — AI가 도구를 쓰겠다고 하면 실제로 실행해주고 그 결과를 다시 AI에게 돌려주길 반복하는 진행 관리자 _AI가 '이 도구를 써 달라'고 하면 승인을 받은 뒤 도구를 실행하고 결과를 다시 AI에게 전달하는 일을 도구 호출이 끝날 때까지 되풀이한다. 무한 반복을 막으려 최대 8번으로 제한하고, 그동안 쓴 비용(토큰)을 합산하며, 알 수 없는 도구나 승인 거부·실행 오류는 오류로 표시해 돌려준다._ - →의존: providers/types, tools/context, tools/types · ←피의존: session/api-session · 225줄 - **tools/workspace-tools** — 작업 폴더 안의 파일을 읽고 찾아보게 해주는 안전한 읽기 전용 도구 4종 세트 _파일 읽기·폴더 목록·내용 검색(grep)·파일 찾기(glob) 네 가지 도구를 만들며, 모두 지정한 작업 폴더 밖으로는 절대 나가지 못하게 막고 비밀번호 같은 민감 파일은 건너뛴다. 너무 큰 파일이나 많은 결과는 일부만 보여주고, 위험할 수 있는 검색 패턴은 미리 거부해 앱이 멈추지 않게 보호한다._ - - →의존: safety/approval, tools/types · ←피의존: engine · 379줄 + - →의존: safety/approval, tools/types, workspace/path-guard · ←피의존: engine · 366줄 - **tools/context** — 대화 기록이 너무 길어지지 않게 오래된 도구 결과를 짧은 표식으로 줄여주는 정리 담당 _AI에게 보내는 대화가 정해진 분량을 넘으면, 예전에 받았던 도구 실행 결과 내용을 '이전 도구 결과 정리됨'이라는 짧은 문구로 바꿔 자리를 비운다. 최근 결과 몇 개와 방금 막 만든 결과는 그대로 보존하고, 글자 수를 대략 세어(영어는 4글자에 한 토막, 한글·한자는 글자마다 한 토막으로 넉넉히) 한도 초과를 미리 막는다._ - →의존: providers/types · ←피의존: tools/loop · 154줄 - **tools/registry** — 여러 도구를 이름표로 정리해 이름만 대면 바로 찾아 쓰게 해주는 도구 명부 _넘겨받은 도구들을 이름표 순으로 정리해, 이름으로 도구를 찾거나 목록을 뽑아낼 수 있게 한다. 같은 이름의 도구가 두 번 들어오면 헷갈림을 막기 위해 충돌 오류를 낸다._ - →의존: tools/types · ←피의존: engine · 17줄 +### workspace · core — AI들이 작업방에서 코드를 고칠 때, 시작 시점을 기록해 두고 무엇이 바뀌었는지 보여주거나 통째로 되돌릴 수 있게 해주는 안전장치 모듈. +- **workspace/git** — AI가 코드를 고치기 전 상태를 저장해 두고, 바뀐 내용을 모아 보여주거나 처음으로 되돌리는 작업 기록 관리원 _작업방을 버전 관리 저장소(git)로 만들어 '시작 사진'을 찍어두고, AI가 무엇을 바꿨는지 변경 목록과 그 내용(diff)을 모아 보여주거나, 마음에 안 들면 시작 사진 시점으로 통째로 되돌립니다. 사용자가 미리 만들어둔 작업은 시작 때 따로 보존해 지워지지 않게 하고, 여러 AI가 동시에 저장소를 건드려 생기는 잠금 충돌은 잠깐 기다렸다 다시 시도하며, 변경 내용이 너무 길면 6만 자에서 잘라 보여줍니다._ + - →의존: cli/detect, workspace/ignored-baseline · ←피의존: engine, orchestrator/diff-risk, orchestrator/ignored-guard, orchestrator/orchestrator, workspace/ignored-baseline · 239줄 +- **workspace/ignored-baseline** + - →의존: safety/approval, workspace/git, workspace/path-guard · ←피의존: orchestrator/diff-risk, orchestrator/ignored-guard, orchestrator/orchestrator, workspace/git · 415줄 +- **workspace/path-guard** + - →의존: — · ←피의존: tools/workspace-tools, workspace/ignored-baseline · 70줄 + ### store · core — 앱이 다루는 모든 데이터(프로젝트, 할 일, 채팅방, 대화, 기록, AI 연결 정보)를 한곳에 모아 보관하고, 컴퓨터를 껐다 켜도 그대로 남도록 파일에 저장하는 '데이터 창고'다. - **store/types** — 창고에 담기는 데이터들의 모양과 규칙을 미리 적어 둔 설계도 부품 _프로젝트·할 일·채팅방·저장된 AI 세션 등이 각각 어떤 항목들로 이뤄지는지 형태를 정의한 명세서다. 특히 AI 연결 정보는 구독형 CLI(클로드·코덱스 등)와 API 두 종류로 나뉘며, API 키 같은 비밀번호는 절대 그대로 적지 않고 암호로 바꾼 형태만 저장하도록 규칙을 못 박아 둔다._ - →의존: shared/types · ←피의존: chat/room, engine, main/index, orchestrator/orchestrator, store/json-file, store/memory · 151줄 @@ -142,12 +150,6 @@ - **store/json-file** — 데이터를 컴퓨터 안 파일에 안전하게 저장해 두는 보관 담당 부품 _앱이 다루는 모든 정보를 'fleet-store.json'이라는 파일에 적어두고, 다음에 앱을 켜면 다시 불러온다. 저장할 때는 임시 파일에 먼저 쓴 뒤 이름만 바꿔치기해서 도중에 멈춰도 원본이 안 깨지게 하고, 파일이 읽다가 망가져 있으면 '.corrupt' 라는 이름으로 따로 백업해 둔 뒤 빈 상태로 시작한다._ - →의존: store/memory, store/types · ←피의존: main/index · 60줄 -### workspace · core — AI들이 작업방에서 코드를 고칠 때, 시작 시점을 기록해 두고 무엇이 바뀌었는지 보여주거나 통째로 되돌릴 수 있게 해주는 안전장치 모듈. -- **workspace/git** — AI가 코드를 고치기 전 상태를 저장해 두고, 바뀐 내용을 모아 보여주거나 처음으로 되돌리는 작업 기록 관리원 _작업방을 버전 관리 저장소(git)로 만들어 '시작 사진'을 찍어두고, AI가 무엇을 바꿨는지 변경 목록과 그 내용(diff)을 모아 보여주거나, 마음에 안 들면 시작 사진 시점으로 통째로 되돌립니다. 사용자가 미리 만들어둔 작업은 시작 때 따로 보존해 지워지지 않게 하고, 여러 AI가 동시에 저장소를 건드려 생기는 잠금 충돌은 잠깐 기다렸다 다시 시도하며, 변경 내용이 너무 길면 6만 자에서 잘라 보여줍니다._ - - →의존: cli/detect, workspace/ignored-baseline · ←피의존: engine, orchestrator/diff-risk, orchestrator/ignored-guard, orchestrator/orchestrator, workspace/ignored-baseline · 239줄 -- **workspace/ignored-baseline** - - →의존: safety/approval, workspace/git · ←피의존: orchestrator/diff-risk, orchestrator/ignored-guard, orchestrator/orchestrator, workspace/git · 361줄 - ### cli · core — 클로드·코덱스·제미니 같은 명령어형 AI 프로그램(CLI)이 컴퓨터에 깔려 있는지 확인하고, 그 프로그램을 실제로 실행해 답변 글자만 깔끔하게 뽑아내며, 각 프로그램의 사용법(명령어 종류)을 한곳에 정리해 두는 모듈이다. - **cli/detect** — AI 명령어 프로그램을 실제로 실행하고, 깔려 있는지·어느 버전인지 확인하는 부품 _사람이 터미널에 명령어를 치듯 클로드·코덱스·제미니 프로그램을 대신 실행해 그 결과(출력 글자)를 받아온다. '--version'을 물어 설치 여부와 버전을 알아내고, 응답이 너무 오래 걸리거나(시간초과) 사용자가 중간에 취소하면 그 프로그램과 거기서 또 생긴 자식 프로그램들까지 끝까지 종료시킨 뒤 마무리한다. 여러 AI를 한꺼번에 동시 점검하는 기능도 있다._ - →의존: process/kill-tree, shared/types · ←피의존: engine, main/e2e, session/cli-session, workspace/git · 208줄 diff --git a/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md b/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md index 737adb1..005798a 100644 --- a/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md +++ b/docs/superpowers/plans/2026-06-23-issue128-B2-link-guard.md @@ -109,7 +109,9 @@ Expected: FAIL — `Failed to resolve import "./path-guard"`. ```ts // src/main/core/workspace/path-guard.ts -import { lstatSync } from 'node:fs' +// namespace import — 테스트가 vi.spyOn(fs,'lstatSync')로 가로챌 수 있게 한다(ESM named-import +// 바인딩은 스파이가 가로채지 못함 — B1 m4 교훈). +import * as fs from 'node:fs' /** 경로의 종류를 lstat(링크 비추종)으로 판정한다. * 'link' = POSIX symlink 또는 Windows junction(둘 다 lstat.isSymbolicLink()=true, 실증). @@ -120,7 +122,7 @@ export type LinkKind = 'regular' | 'dir' | 'link' | 'suspicious' | 'missing' export function isLinkSync(abs: string): LinkKind { try { - const st = lstatSync(abs) + const st = fs.lstatSync(abs) if (st.isSymbolicLink()) return 'link' if (st.isDirectory()) return 'dir' if (st.isFile()) return 'regular' @@ -214,7 +216,7 @@ Expected: FAIL — `resolveWithin is not a function` / import 실패. - [ ] **Step 3: 최소 구현** (`path-guard.ts`에 추가) ```ts -import { lstatSync, realpathSync, existsSync } from 'node:fs' +// (fs 는 Task 1 의 `import * as fs from 'node:fs'` 를 그대로 사용 — 추가 import 없음) import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' // win32 비교는 case-insensitive(NTFS) — 양변 case-fold. @@ -227,7 +229,7 @@ const fold = (p: string): string => (process.platform === 'win32' ? p.toLowerCas export function resolveWithin(root: string, p: string): string { let realRoot: string try { - realRoot = realpathSync.native(root) + realRoot = fs.realpathSync.native(root) } catch (err) { throw new Error(`워크스페이스 realpath 해소 불가(운영 에러): ${root}`, { cause: err }) } @@ -235,7 +237,7 @@ export function resolveWithin(root: string, p: string): string { // 최근접 존재 조상까지 올라가 그 조상의 realpath 를 구하고 미존재 tail 을 재부착한다. let existingAbs = abs const tail: string[] = [] - while (!existsSync(existingAbs)) { + while (!fs.existsSync(existingAbs)) { tail.unshift(basename(existingAbs)) const parent = dirname(existingAbs) if (parent === existingAbs) break @@ -243,7 +245,7 @@ export function resolveWithin(root: string, p: string): string { } let realCandidate: string try { - const realExisting = realpathSync.native(existingAbs) + const realExisting = fs.realpathSync.native(existingAbs) realCandidate = tail.length ? resolve(realExisting, ...tail) : realExisting } catch (err) { throw new Error(`경로 realpath 해소 실패(안전상 거부): ${p}`, { cause: err }) @@ -809,10 +811,17 @@ git commit -m "refactor(#128-B2): workspace-tools containment를 path-guard help Run: `npm run brain` Expected: `brain.md`가 신규 `path-guard.ts`/변경 반영하여 갱신됨(diff 발생). -- [ ] **Step 3: 5게이트 전체 실행** +- [ ] **Step 3: 5게이트 실행** + +Run: `npm run typecheck && npm test && npm run build` +Expected: 전부 통과. -Run: `npm run typecheck && npm run lint && npm run format:check && npm test && npm run build` -Expected: 전부 통과. (`format:check` 실패 시 `npm run format` 후 재실행·재커밋.) +**환경 함정(B1 교훈):** `npm run lint`(`eslint .`)·`npm run format:check`(`prettier --check .`)를 레포 전체로 돌리면 **스테일 `.claude/worktrees`·untracked `.dogfood`로 우리 변경과 무관하게 실패**한다. 따라서 변경 파일만 검증한다: +``` +npx eslint src/main/core/workspace/path-guard.ts src/main/core/workspace/path-guard.test.ts src/main/core/workspace/ignored-baseline.ts src/main/core/workspace/ignored-baseline.test.ts src/main/core/tools/workspace-tools.ts src/main/core/tools/workspace-tools.test.ts +npx prettier --check src/main/core/workspace/path-guard.ts src/main/core/workspace/ignored-baseline.ts src/main/core/tools/workspace-tools.ts +``` +Expected: EXIT 0(변경 파일). prettier 실패 시 `npx prettier --write ` 후 재커밋. 머지 게이트는 CI required check(`typecheck·lint·test·build·win32 보안 회귀`)가 클린 환경에서 강제. - [ ] **Step 4: 커밋** diff --git a/src/main/core/workspace/path-guard.ts b/src/main/core/workspace/path-guard.ts index 0a1496a..68a188d 100644 --- a/src/main/core/workspace/path-guard.ts +++ b/src/main/core/workspace/path-guard.ts @@ -1,3 +1,8 @@ +// "경로검사 ≠ 격리"(advisory). 이 모듈은 *Fleet 자체 FS 연산*이 symlink/junction 을 따라가 +// 워크스페이스 밖을 읽거나 쓰는 것을 줄이는 advisory guard다. 스폰된 CLI 의 직접 쓰기는 막지 못하며, +// lstat→open/write 사이 TOCTOU 창이 남는다(순수 Node 는 openat2/O_NOFOLLOW 크로스플랫폼 부재 — +// Windows 엔 O_NOFOLLOW 자체 없음). 강한 격리는 OS/CLI 샌드박스 층(#128 향후·문서 참조). +// // namespace import — spy 가로채기가 환경에서 허용될 때 vi.spyOn(fs,'lstatSync')가 동작하도록 // 한다(ESM named-import 바인딩은 스파이가 가로채지 못함 — B1 m4 교훈). // Windows ESM 환경에서는 Node 빌트인 프로퍼티가 non-configurable이라 spy가 "Cannot redefine From 36f3e17f43b8e19b809dd8f4f35fc5b16c9ec28b Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 15:59:57 +0900 Subject: [PATCH 12/19] =?UTF-8?q?test(#128-B2):=20win32=20junction=20leaf-?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EC=BB=A4=EB=B2=84=20+=20sensitive=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=EC=8B=9D=20=EC=A2=81=ED=9E=98=20+=20rollback?= =?UTF-8?q?=20f.dat=20=EB=B3=B5=EC=9B=90=20=EB=8B=A8=EC=96=B8=20+=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 27 ++++++++++++++++++- src/main/core/workspace/ignored-baseline.ts | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 2815808..1061abb 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -744,7 +744,7 @@ describe.skipIf(process.platform === 'win32')('[#128-B2] capture 링크 leaf (PO symlinkSync(join(outside, 'k'), join(root, '.env')) const git = fakeGitIgnored(['.env']) await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( - /링크|일반 파일이 아님/, + /링크/, ) } finally { rmSync(outside, { recursive: true, force: true }) @@ -783,6 +783,7 @@ describe.skipIf(process.platform === 'win32')( await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) expect(existsSync(join(root, 'esc'))).toBe(false) // 링크 제거됨 expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') // 밖 내용 보존 + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') // baseline 복원 } finally { rmSync(outside, { recursive: true, force: true }) } @@ -800,6 +801,7 @@ describe.skipIf(process.platform === 'win32')( await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) expect(existsSync(join(root, 'newlink'))).toBe(false) expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') // baseline 복원 } finally { rmSync(outside, { recursive: true, force: true }) } @@ -824,11 +826,34 @@ describe.skipIf(process.platform !== 'win32')( await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) expect(existsSync(join(root, 'esc'))).toBe(false) // junction 제거됨 expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') // 밖 내용 보존 + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') // baseline 복원 } finally { rmSync(outside, { recursive: true, force: true }) } }) + it('junction leaf 교체 → junction 제거 후 root 안 실파일로 복원(밖 내용 미오염)', async () => { + // win32 보안 회귀: baseline 일반파일 f.dat 가 junction 으로 교체됐을 때 + // restore 가 junction 을 unlink 하고 backup('orig')으로 실파일 복원하는지 검증. + // lstatSync→rmSync→writeFileSync 경로(leaf restore)의 win32 커버. + writeFileSync(join(root, 'f.dat'), 'orig') + const git = fakeGitIgnored(['f.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-jleaf-')) + try { + writeFileSync(join(outsideDir, 'keep'), 'KEEP') + rmSync(join(root, 'f.dat')) + symlinkSync(outsideDir, join(root, 'f.dat'), 'junction') + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + // leaf が junction から実ファイルとして復元される + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') + // 밖 내용은 오염되지 않아야 함 + expect(readFile(join(outsideDir, 'keep')).toString()).toBe('KEEP') + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) + it('junction 조상 → clearNonDirAncestors 제거 후 실파일 in-root 복원(밖 내용 보존)', async () => { // baseline: a/b/c.txt mkdirSync(join(root, 'a', 'b'), { recursive: true }) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 8732f58..5f8db3b 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -175,7 +175,7 @@ export async function captureIgnoredBaseline( const abs = resolve(root, path) let st try { - st = lstatSync(abs) // [#128-B2] 링크 비추종 — 링크면 isFile()=false 로 아래 분기 적중 + st = lstatSync(abs) // [#128-B2] 링크 비추종 — symlink 은 아래 isSymbolicLink() 분기에서 먼저 차단 } catch (err) { if (sensitive) throw new Error(`민감 ignored 파일 stat 실패: ${path}`, { cause: err }) skipped.push({ path, reason: 'read-failed' }) From 9448e791bb265e4388849004d595a908098a7a0c Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 16:33:30 +0900 Subject: [PATCH 13/19] =?UTF-8?q?fix(#128-B2):=20Codex=20PR=20P1/P2=20?= =?UTF-8?q?=E2=80=94=20=EB=AF=BC=EA=B0=90=20symlink=20fail-closed=C2=B7dan?= =?UTF-8?q?gling=20link=20leaf/ancestor=20=EC=B6=94=EC=A2=85=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8=20fixture=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 85 +++++++++++++++++-- src/main/core/workspace/ignored-baseline.ts | 62 ++++++++++---- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 1061abb..f32da4b 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -772,15 +772,71 @@ describe.skipIf(process.platform === 'win32')( rmSync(outside, { recursive: true, force: true }) } }) - it('created symlink-to-dir 는 링크만 unlink(밖 디렉터리 내용 보존)', async () => { - const git = fakeGitIgnored(['f.dat', 'esc']) // f.dat=baseline, esc=created link + it('dangling symlink leaf → 실파일 복원, outside write-through 없음 [P1-2]', async () => { + // baseline: f.dat='orig' writeFileSync(join(root, 'f.dat'), 'orig') + const git = fakeGitIgnored(['f.dat']) const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + // f.dat 을 dangling symlink 로 교체 (outsideDir/ghost 는 존재하지 않음) + rmSync(join(root, 'f.dat')) + symlinkSync(join(outsideDir, 'ghost'), join(root, 'f.dat')) + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + // root/f.dat 은 실파일('orig')로 복원되어야 함 + expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') + // fleet 이 dangling link 를 통해 outside 에 파일을 쓰지 않아야 함 + expect(existsSync(join(outsideDir, 'ghost'))).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) + + it('dangling symlink 조상 → 실디렉터리 재건, outside write-through 없음 [P1-3]', async () => { + // baseline: a/b/c.dat='deep' + mkdirSync(join(root, 'a', 'b'), { recursive: true }) + writeFileSync(join(root, 'a', 'b', 'c.dat'), 'deep') + const git = fakeGitIgnored(['a/b/c.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + // a/ 를 제거하고 dangling symlink 로 교체 (outsideDir/ghostdir 는 존재하지 않음) + rmSync(join(root, 'a'), { recursive: true, force: true }) + symlinkSync(join(outsideDir, 'ghostdir'), join(root, 'a')) + await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + // a/b/c.dat 이 root 안에 실파일로 복원되어야 함 + expect(readFile(join(root, 'a', 'b', 'c.dat')).toString()).toBe('deep') + // fleet 이 dangling link 를 통해 outside 에 디렉터리를 만들지 않아야 함 + expect(existsSync(join(outsideDir, 'ghostdir'))).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) + + it('민감 symlink → captureIgnoredBaseline throws [P1-1]', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + symlinkSync(join(outsideDir, 'whatever'), join(root, '.env')) + const git = fakeGitIgnored(['.env']) + await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( + /링크/, + ) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) + + it('created symlink-to-dir 는 링크만 unlink(밖 디렉터리 내용 보존)', async () => { + // P2-4: baseline 에 esc 없음(분리 fixture) — capture 시 esc 미보고 + writeFileSync(join(root, 'f.dat'), 'orig') + const baseGit = fakeGitIgnored(['f.dat']) // esc NOT reported at capture + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) try { writeFileSync(join(outside, 'keep'), 'KEEP') symlinkSync(outside, join(root, 'esc'), 'dir') // 실행 중 생성된 링크 - await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + const curGit = fakeGitIgnored(['f.dat', 'esc']) // esc reported at restore + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) expect(existsSync(join(root, 'esc'))).toBe(false) // 링크 제거됨 expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') // 밖 내용 보존 expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') // baseline 복원 @@ -790,15 +846,16 @@ describe.skipIf(process.platform === 'win32')( }) it('실행 중 새로 생긴 escaping symlink 는 rollback 에서 unlink(밖 내용 보존)', async () => { + // P2-4: baseline 에 newlink 없음(분리 fixture) writeFileSync(join(root, 'f.dat'), 'orig') - const baseGit = fakeGitIgnored(['f.dat']) + const baseGit = fakeGitIgnored(['f.dat']) // newlink NOT reported at capture const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) const outside = mkdtempSync(join(tmpdir(), 'fleet-out-')) try { writeFileSync(join(outside, 'keep'), 'KEEP') symlinkSync(outside, join(root, 'newlink'), 'dir') // 에이전트가 새로 만든 링크 - const git = fakeGitIgnored(['f.dat', 'newlink']) // restore 시점 git 보고 - await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) + const curGit = fakeGitIgnored(['f.dat', 'newlink']) // restore 시점 git 보고 + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) expect(existsSync(join(root, 'newlink'))).toBe(false) expect(readFile(join(outside, 'keep')).toString()).toBe('KEEP') expect(readFile(join(root, 'f.dat')).toString()).toBe('orig') // baseline 복원 @@ -868,8 +925,7 @@ describe.skipIf(process.platform !== 'win32')( symlinkSync(outside, join(root, 'a'), 'junction') await restoreIgnoredBaseline(root, git, base, DEFAULT_IGNORED_POLICY) // junction 이 제거되고 a/b/c.txt 가 in-root 에 복원됨 - const { statSync: st } = await import('node:fs') - expect(st(join(root, 'a')).isDirectory()).toBe(true) + expect(statSync(join(root, 'a')).isDirectory()).toBe(true) expect(readFile(join(root, 'a', 'b', 'c.txt')).toString()).toBe('ORIG') // 밖 victim 미오염 expect(readFile(join(outside, 'victim')).toString()).toBe('OUTSIDE') @@ -877,5 +933,18 @@ describe.skipIf(process.platform !== 'win32')( rmSync(outside, { recursive: true, force: true }) } }) + + it('민감 junction(.env) → captureIgnoredBaseline throws [P1-1 win32]', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-out-')) + try { + symlinkSync(outsideDir, join(root, '.env'), 'junction') + const git = fakeGitIgnored(['.env']) + await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( + /링크/, + ) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) }, ) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 5f8db3b..295493e 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -13,7 +13,6 @@ import { import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { SENSITIVE_FILE } from '../safety/approval' import type { GitRunner } from './git' -import { isLinkSync } from './path-guard' /** [:270] walk의 unreadable-dir 및 listIgnored의 over-cap 합성 마커 경로 상수. * restore/rmSync 등 실-경로로 취급하는 곳에서는 반드시 이 값을 걸러내야 한다. */ @@ -138,16 +137,29 @@ async function listIgnored( // evasion(숨긴 위치 쓰기) 방어는 경로검사가 아닌 B2 프로세스 격리로 이관(#128 잔여). if (policy.denylistRe.test(`${dir}/`)) continue // [#128-B2] git 이 디렉터리로 보고해도 실제 junction/symlink 면 재귀 금지. - // 'suspicious'/'missing' 은 이 분기에 걸리지 않아 pushFile/missing 경로로 떨어지며, - // capture 의 lstat 이 non-regular 로 표면화한다(Task 4 동작 — 여기선 의도적 미처리). - if (isLinkSync(resolve(root, dir)) === 'link') { + let dirSt + try { + dirSt = lstatSync(resolve(root, dir)) + } catch { + // 존재하지 않거나 접근 불가 → walk 로 넘겨 fail-closed 처리 + walk(dir) + continue + } + if (dirSt.isSymbolicLink()) { skipped.push({ path: dir, reason: 'symlink' }) continue } walk(dir) } else { // [#128-B2] 파일로 보고된 엔트리가 실제 링크면 수집 안 함(capture 의 lstat 가 이중 방어). - if (isLinkSync(resolve(root, rel)) === 'link') { + let fileSt + try { + fileSt = lstatSync(resolve(root, rel)) + } catch { + pushFile(rel) + continue + } + if (fileSt.isSymbolicLink()) { skipped.push({ path: rel, reason: 'symlink' }) continue } @@ -163,6 +175,13 @@ export async function captureIgnoredBaseline( policy: ScanPolicy, ): Promise { const { files, skipped: enumSkipped } = await listIgnored(root, git, policy) + // [#128-B2 P1] listIgnored 가 링크를 skipped{symlink} 로 우회시키므로 민감-명 링크가 아래 + // sensitive→throw 분기에 도달하지 못한다. 여기서 fail-closed(민감 경로 백업 불가 → hard-stop). + for (const s of enumSkipped) { + if (s.reason === 'symlink' && policy.sensitiveRe.test(s.path)) { + throw new Error(`민감 ignored 파일이 링크임(백업 불가): ${s.path}`) + } + } const entries = new Map() const skipped: { path: string @@ -337,14 +356,13 @@ function clearNonDirAncestors(root: string, abs: string): void { let cur = root for (const part of relDir.split(/[\\/]/).filter(Boolean)) { cur = resolve(cur, part) - if (!existsSync(cur)) continue let ls try { - ls = lstatSync(cur) // [#128-B2] existsSync→lstat race 는 fail-safe(advisory) - } catch { - continue + ls = lstatSync(cur) // [#128-B2 P1] existsSync 는 symlink 추종→dangling 조상 놓침. lstat 직접. + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue // 진짜 부재 + continue // 기타(race 등) fail-safe(advisory) } - // [#128-B2] 비-dir 또는 링크(junction 포함) 조상은 제거 → mkdirSync(recursive) 가 root 안 체인 재생성. if (!ls.isDirectory() || ls.isSymbolicLink()) { rmSync(cur, { recursive: true, force: true }) return @@ -370,8 +388,14 @@ export async function restoreIgnoredBaseline( // git-상대 경로는 보통 root 아래지만, 이상한 절대/상위 경로 방어. const r = relative(root, abs) if (r === '..' || r.startsWith(`..${sep}`) || isAbsolute(r)) return - // 링크면 recursive 금지 — 링크 자체만 unlink(밖 내용 보존). isLinkSync=lstat(비추종). - rmSync(abs, isLinkSync(abs) === 'link' ? { force: true } : { recursive: true, force: true }) + // 링크면 recursive 금지 — 링크 자체만 unlink(밖 내용 보존). lstatSync(비추종). + let st + try { + st = lstatSync(abs) + } catch { + return + } + rmSync(abs, st.isSymbolicLink() ? { force: true } : { recursive: true, force: true }) } // 1) created(현재 in-scope, baseline·skipped 둘 다 없음) → 삭제. for (const path of files) { @@ -391,12 +415,14 @@ export async function restoreIgnoredBaseline( const abs = resolve(root, path) clearNonDirAncestors(root, abs) // [#128-B] 조상-파일 충돌 정리 mkdirSync(dirname(abs), { recursive: true }) - // [P1-b] if existing path is not a regular file (e.g. directory), remove it first - if (existsSync(abs)) { - const st = lstatSync(abs) // [#128-B2] 비추종 — symlink leaf 는 isFile()=false 로 제거 대상 - if (!st.isFile()) { - rmSync(abs, { recursive: true, force: true }) // 링크/디렉터리 제거 후 실파일로 복원 - } + let leafSt: import('node:fs').Stats | undefined + try { + leafSt = lstatSync(abs) // [#128-B2 P1] existsSync 는 symlink 추종→dangling link 를 놓침. lstat 직접. + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e // 진짜 부재만 무시, 그 외 fail-closed + } + if (leafSt && !leafSt.isFile()) { + rmSync(abs, { recursive: true, force: true }) // 디렉터리/(dangling 포함)링크 leaf 제거 후 실파일 복원 } writeFileSync(abs, entry.backup) // [P1-a] restore original file mode From f726d93fdf34673b35e3085919f9f150e75a0d4b Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 16:52:19 +0900 Subject: [PATCH 14/19] =?UTF-8?q?fix(#128-B2):=20Codex=20=EC=9E=AC?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20P1=20=E2=80=94=20=EB=AF=BC=EA=B0=90=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20symlink(.ssh/)=20fail-cl?= =?UTF-8?q?osed(=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EC=96=91=ED=98=95=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listIgnored top-level 루프가 '.ssh/' → '.ssh'(trailing slash 제거)로 skipped에 push. SENSITIVE_FILE .ssh 절은 `(^|[/\])\.ssh[/\]` — trailing slash 필수이므로 s.path='.ssh' 단독 테스트 시 false → fail-open 취약. captureIgnoredBaseline fail-closed 루프에서 s.path 와 `${s.path}/` 양형을 OR 테스트하도록 수정. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 36 +++++++++++++++++++ src/main/core/workspace/ignored-baseline.ts | 11 +++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index f32da4b..f5d7ae4 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -826,6 +826,24 @@ describe.skipIf(process.platform === 'win32')( } }) + // [Codex 재리뷰 P1] 민감 디렉터리 SYMLINK(.ssh) → fail-closed + // listIgnored 는 '.ssh/' → '.ssh'(trailing slash 제거) 로 skipped 에 push. + // SENSITIVE_FILE 의 .ssh 절은 trailing slash 필수이므로 s.path='.ssh' 만 테스트하면 false(fail-open). + // 수정: `${s.path}/` 형도 함께 테스트 → '.ssh/' 가 정규식과 매칭 → throw(fail-closed). + it('[Codex P1] 민감 DIRECTORY symlink(.ssh) → captureIgnoredBaseline throws [P1-1-dir POSIX]', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-ssh-')) + try { + symlinkSync(outsideDir, join(root, '.ssh'), 'dir') + // git 은 디렉터리 심볼릭 링크를 '.ssh/'(trailing slash 포함)로 보고한다 + const git = fakeGitIgnored(['.ssh/']) + await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( + /링크/, + ) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) + it('created symlink-to-dir 는 링크만 unlink(밖 디렉터리 내용 보존)', async () => { // P2-4: baseline 에 esc 없음(분리 fixture) — capture 시 esc 미보고 writeFileSync(join(root, 'f.dat'), 'orig') @@ -946,5 +964,23 @@ describe.skipIf(process.platform !== 'win32')( rmSync(outsideDir, { recursive: true, force: true }) } }) + + // [Codex 재리뷰 P1] 민감 디렉터리 JUNCTION(.ssh) → fail-closed + // listIgnored 는 '.ssh/' → '.ssh'(trailing slash 제거) 로 skipped 에 push. + // SENSITIVE_FILE 의 .ssh 절은 trailing slash 필수이므로 s.path='.ssh' 만 테스트하면 false(fail-open). + // 수정: `${s.path}/` 형도 함께 테스트 → '.ssh/' 가 정규식과 매칭 → throw(fail-closed). + it('[Codex P1] 민감 DIRECTORY junction(.ssh) → captureIgnoredBaseline throws [P1-1-dir win32]', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-ssh-')) + try { + symlinkSync(outsideDir, join(root, '.ssh'), 'junction') + // git 은 디렉터리 심볼릭 링크를 '.ssh/'(trailing slash 포함)로 보고한다 + const git = fakeGitIgnored(['.ssh/']) + await expect(captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY)).rejects.toThrow( + /링크/, + ) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) }, ) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 295493e..9cfb41e 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -177,8 +177,17 @@ export async function captureIgnoredBaseline( const { files, skipped: enumSkipped } = await listIgnored(root, git, policy) // [#128-B2 P1] listIgnored 가 링크를 skipped{symlink} 로 우회시키므로 민감-명 링크가 아래 // sensitive→throw 분기에 도달하지 못한다. 여기서 fail-closed(민감 경로 백업 불가 → hard-stop). + // [Codex 재리뷰 P1] listIgnored top-level 루프: `dir = rel.replace(/\/+$/, '')` 로 + // 디렉터리 심볼릭 링크의 trailing slash 가 제거됨(예: '.ssh/' → '.ssh'). + // SENSITIVE_FILE 의 .ssh 절은 `(^|[/\\])\.ssh[/\\]` — trailing slash 필수. + // 따라서 s.path='.ssh' 는 test false → fail-open 위험. + // 두 형태 모두 검사: s.path(파일형) AND `${s.path}/`(디렉터리형, slash 복원)로 + // 슬래시가 제거된 디렉터리 심볼릭 링크도 확실히 차단한다. for (const s of enumSkipped) { - if (s.reason === 'symlink' && policy.sensitiveRe.test(s.path)) { + if ( + s.reason === 'symlink' && + (policy.sensitiveRe.test(s.path) || policy.sensitiveRe.test(`${s.path}/`)) + ) { throw new Error(`민감 ignored 파일이 링크임(백업 불가): ${s.path}`) } } From 558ca83fd2ad827853eaf73ff31a894357fa867f Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 17:24:08 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix(#128-B2):=20Codex=20=EC=9E=AC?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20round3=20=E2=80=94=20=EB=B6=84=EB=A5=98?= =?UTF-8?q?=EB=B6=88=EA=B0=80=20dir=20walk/=EC=A1=B0=EC=83=81=20write=20fa?= =?UTF-8?q?il-closed=C2=B7=EB=AF=BC=EA=B0=90=20skip=20=EA=B4=91=EC=97=AD?= =?UTF-8?q?=ED=99=94=C2=B7baseline=20symlink=20=EC=B9=98=ED=99=98=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 86 +++++++++++++++++-- src/main/core/workspace/ignored-baseline.ts | 35 +++++--- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index f5d7ae4..cc8187f 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -579,17 +579,17 @@ describe.skipIf(process.platform !== 'win32')('[#128-B2] junction 비추종 (Win // ── Codex 3차 반영: [:96] [:109] [:244] [:270] ── -describe('[:96] unreadable dir → fail-closed (walk readdirSync catch)', () => { - it('walk 에서 readdirSync 가 실패하면 SCAN_CAPPED over-cap escalation 이 기록된다(not silent drop)', async () => { - // 존재하지 않는 경로를 git status 로 반환 → walk 가 readdirSync 실패 → fail-closed - // (Windows: 권한변경이 제한적이므로 존재하지 않는 경로로 unreadable 시뮬레이션) +describe('[:96] unreadable dir → fail-closed (lstat catch in top-level loop)', () => { + it('[Fix-B] lstat 실패 디렉터리는 walk 하지 않고 skipped{symlink} 로 fail-closed 기록된다', async () => { + // 존재하지 않는 경로를 git status 로 반환 → lstat ENOENT → fail-closed(walk 안 함) + // Fix-B: lstat catch 시 skipped{path:dir, reason:'symlink'} 로 push, walk 금지. + // (ENOENT 포함 모든 lstat 실패를 동일하게 처리 — exotic reparse 방어 포함) const git = fakeGitIgnored(['nonexistent-dir/']) - // nonexistent-dir/ 는 실제로 없으므로 readdirSync → ENOENT const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) - // fail-closed: skipped 에 over-cap escalation 이 기록되어야 한다 - const overCapSkipped = base.skipped.filter((s) => s.reason === 'over-cap') - expect(overCapSkipped.length).toBeGreaterThanOrEqual(1) - expect(overCapSkipped[0].path).toBe(SCAN_CAPPED) + // fail-closed: skipped 에 'nonexistent-dir' 가 symlink 이유로 기록되어야 한다 + expect(base.skipped).toContainEqual({ path: 'nonexistent-dir', reason: 'symlink' }) + // walk 하지 않았으므로 entries 는 비어 있어야 한다 + expect(base.entries.size).toBe(0) }) }) @@ -702,6 +702,52 @@ describe('[:270] scan-capped marker restore exclusion', () => { }) }) +// [Codex P1 Fix-D] baseline symlink → 일반파일 교체 rollback 검증 (POSIX) +describe.skipIf(process.platform === 'win32')( + '[Fix-D] baseline symlink 경로를 일반파일로 교체 시 rollback (POSIX)', + () => { + it('[Fix-D collect] baseline symlink 를 일반파일로 교체 시 collectIgnoredChanges 가 created 로 보고', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-fixd-')) + try { + // baseline: link.dat 는 symlink → capture 가 skipped{symlink} 로 기록 + symlinkSync(join(outside, 'target'), join(root, 'link.dat')) + const git = fakeGitIgnored(['link.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link.dat', reason: 'symlink' }) + // 에이전트가 symlink 를 제거하고 일반파일로 교체 + rmSync(join(root, 'link.dat')) + writeFileSync(join(root, 'link.dat'), 'agent-content') + const curGit = fakeGitIgnored(['link.dat']) + const cs = await collectIgnoredChanges(root, curGit, base, DEFAULT_IGNORED_POLICY) + // symlink 경로가 화이트리스트에서 제외되어 created 로 표면화되어야 한다 + expect(cs.changes).toContainEqual({ path: 'link.dat', change: 'created', sensitive: false }) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + + it('[Fix-D restore] baseline symlink 를 일반파일로 교체 시 restoreIgnoredBaseline 이 삭제한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-fixd-')) + try { + // baseline: link.dat 는 symlink → capture 가 skipped{symlink} 로 기록 + symlinkSync(join(outside, 'target'), join(root, 'link.dat')) + const git = fakeGitIgnored(['link.dat']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link.dat', reason: 'symlink' }) + // 에이전트가 symlink 를 제거하고 일반파일로 교체 + rmSync(join(root, 'link.dat')) + writeFileSync(join(root, 'link.dat'), 'agent-content') + const curGit = fakeGitIgnored(['link.dat']) + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) + // 에이전트가 만든 일반파일이 rollback 으로 삭제되어야 한다 + expect(existsSync(join(root, 'link.dat'))).toBe(false) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + describe.skipIf(process.platform === 'win32')('[#128-B2] collect 링크 교체 (POSIX)', () => { // 깨끗한 distinguisher: symlink target 내용을 baseline 과 *동일*하게 둔다. // - old(statSync 추종): target('orig')을 읽어 hash 가 baseline 과 일치 → '변경 없음' 오판(보안 구멍). @@ -982,5 +1028,27 @@ describe.skipIf(process.platform !== 'win32')( rmSync(outsideDir, { recursive: true, force: true }) } }) + + // [Codex P1 Fix-D] baseline JUNCTION → 일반파일 교체 → rollback 삭제 검증 (win32) + it('[Fix-D win32] baseline JUNCTION 을 일반파일로 교체하면 restore 가 삭제한다', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-fixd-')) + try { + // baseline: link/ 는 junction → capture 가 skipped{symlink} 로 기록 + symlinkSync(outsideDir, join(root, 'link'), 'junction') + const git = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + // 에이전트가 junction 을 제거하고 일반파일로 교체 + rmSync(join(root, 'link'), { recursive: true, force: true }) + writeFileSync(join(root, 'link'), 'agent-content') + // restore 시 git 은 'link' 를 파일로 보고 + const curGit = fakeGitIgnored(['link']) + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) + // 에이전트가 만든 일반파일이 rollback 으로 삭제되어야 한다 + expect(existsSync(join(root, 'link'))).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) }, ) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 9cfb41e..d849112 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -141,8 +141,9 @@ async function listIgnored( try { dirSt = lstatSync(resolve(root, dir)) } catch { - // 존재하지 않거나 접근 불가 → walk 로 넘겨 fail-closed 처리 - walk(dir) + // [Codex P1 Fix-B] lstat 분류 실패(exotic reparse 등) → walk 하면 readdirSync 가 target 을 열거해 밖 탈출. + // fail-closed: skip(표면화), walk 안 함. (ENOENT 면 어차피 수집할 것 없음.) + skipped.push({ path: dir, reason: 'symlink' }) continue } if (dirSt.isSymbolicLink()) { @@ -184,11 +185,11 @@ export async function captureIgnoredBaseline( // 두 형태 모두 검사: s.path(파일형) AND `${s.path}/`(디렉터리형, slash 복원)로 // 슬래시가 제거된 디렉터리 심볼릭 링크도 확실히 차단한다. for (const s of enumSkipped) { - if ( - s.reason === 'symlink' && - (policy.sensitiveRe.test(s.path) || policy.sensitiveRe.test(`${s.path}/`)) - ) { - throw new Error(`민감 ignored 파일이 링크임(백업 불가): ${s.path}`) + if (s.path === SCAN_CAPPED) continue // 합성 over-cap 마커는 실경로 아님 + // listIgnored 가 분류 못 한(링크/분류불가) 경로는 capture 가 안전 백업 불가 → 민감하면 fail-closed. + // listIgnored top-level 이 디렉터리 링크의 trailing slash 를 떼므로(.ssh/ → .ssh) 두 형태 모두 검사. + if (policy.sensitiveRe.test(s.path) || policy.sensitiveRe.test(`${s.path}/`)) { + throw new Error(`민감 ignored 경로를 안전하게 백업할 수 없음(링크/분류불가): ${s.path}`) } } const entries = new Map() @@ -275,6 +276,9 @@ export async function collectIgnoredChanges( // [P2-4] capture current-scan skipped too const { files, skipped: currentSkipped } = await listIgnored(root, git, policy) const skippedPaths = new Set(baseline.skipped.map((s) => s.path)) + const baselineSymlinkPaths = new Set( + baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), + ) const changes: IgnoredChange[] = [] // [P2-4] merge baseline.skipped + currentSkipped, deduplicate by path const seenUnrestorable = new Set(baseline.skipped.map((s) => s.path)) @@ -287,8 +291,11 @@ export async function collectIgnoredChanges( } // created: 현재 in-scope ignored 인데 baseline 에도 skipped 에도 없음. + // [Codex P1 Fix-D] baseline symlink 경로는 화이트리스트에서 제외 — agent 가 symlink 를 일반파일로 + // 교체한 경우 created 로 표면화되어야 한다(over-cap/read-failed 는 계속 whitelist). for (const path of files) { - if (baseline.entries.has(path) || skippedPaths.has(path)) continue + if (baseline.entries.has(path) || (skippedPaths.has(path) && !baselineSymlinkPaths.has(path))) + continue changes.push({ path, change: 'created', sensitive: policy.sensitiveRe.test(path) }) } // modified / deleted: baseline 엔트리 기준. @@ -369,8 +376,8 @@ function clearNonDirAncestors(root: string, abs: string): void { try { ls = lstatSync(cur) // [#128-B2 P1] existsSync 는 symlink 추종→dangling 조상 놓침. lstat 직접. } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue // 진짜 부재 - continue // 기타(race 등) fail-safe(advisory) + if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue // 진짜 부재 → 다음 조상 + throw e // [Codex P1] 분류 불가 조상(exotic reparse 등) → fail-closed. continue 하면 mkdir/write 가 밖으로 통과. } if (!ls.isDirectory() || ls.isSymbolicLink()) { rmSync(cur, { recursive: true, force: true }) @@ -390,6 +397,9 @@ export async function restoreIgnoredBaseline( // 이번 삭제 패스에서 누락될 수 있음 → rollback 불완전 가능성 표면화). const capped = skipped.some((s) => s.path === SCAN_CAPPED && s.reason === 'over-cap') const skippedPaths = new Set(baseline.skipped.map((s) => s.path)) + const baselineSymlinkPaths = new Set( + baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), + ) // [#128-B2] 링크-aware created 삭제 헬퍼. // lexical containment(realpath 아님 — 탈출 링크도 unlink 해야 하므로 realpath 추종 금지). const removeCreated = (rel: string): void => { @@ -407,8 +417,11 @@ export async function restoreIgnoredBaseline( rmSync(abs, st.isSymbolicLink() ? { force: true } : { recursive: true, force: true }) } // 1) created(현재 in-scope, baseline·skipped 둘 다 없음) → 삭제. + // [Codex P1 Fix-D] baseline symlink 경로는 화이트리스트에서 제외 — agent 가 symlink 를 일반파일로 + // 교체한 경우 created 로 표면화되어 rollback 삭제 대상이 되어야 한다. for (const path of files) { - if (baseline.entries.has(path) || skippedPaths.has(path)) continue + if (baseline.entries.has(path) || (skippedPaths.has(path) && !baselineSymlinkPaths.has(path))) + continue removeCreated(path) } // [:229] over-cap skipped 중 baseline 에 없는 것도 삭제(에이전트가 cap 초과로 만든 파일 rollback). From 23e067ea689da06f97be0744a693aab097fe583a Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 17:51:33 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix(#128-B2):=20Codex=20=EC=9E=AC?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20round4=20P2=20=E2=80=94=20symlink-dir=20?= =?UTF-8?q?=EC=B9=98=ED=99=98=20=EB=A1=A4=EB=B0=B1=C2=B7DT=5FUNKNOWN=20lst?= =?UTF-8?q?at=20=ED=8F=B4=EB=B0=B1=C2=B7removeCreated=20fail-closed=C2=B7?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20skip=20cap=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 121 ++++++++++++++++++ src/main/core/workspace/ignored-baseline.ts | 62 ++++++++- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index cc8187f..91dfca4 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -930,6 +930,98 @@ describe.skipIf(process.platform === 'win32')( }, ) +// [#128-B2 P2-1] baseline symlink-to-dir → agent 가 실제 dir 로 치환 → restore 가 dir 제거 (POSIX) +// RED on pre-fix: git 은 'link/child.txt' 만 보고 → created 루프가 'link' 자체를 못 지움 → dir 잔존. +describe.skipIf(process.platform === 'win32')( + '[P2-1] baseline symlink-to-dir 을 실제 디렉터리로 치환하면 restore 가 그 디렉터리를 제거한다 (POSIX)', + () => { + it('baseline symlink-to-dir → real dir 치환 → restore removes dir', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-p21-')) + try { + // baseline: link 는 symlink → dir → capture 가 skipped{symlink} + symlinkSync(outside, join(root, 'link'), 'dir') + const baseGit = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + + // 에이전트가 symlink 를 제거하고 실제 디렉터리(+자식 파일)로 치환 + rmSync(join(root, 'link'), { force: true }) + mkdirSync(join(root, 'link')) + writeFileSync(join(root, 'link', 'child.txt'), 'agent-child') + + // restore 시 git 은 'link/child.txt' 만 보고(link 자체는 dir 라 보고 안 함) + const curGit = fakeGitIgnored(['link/child.txt']) + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // [P2-1] baseline symlink sweep 이 link 가 더 이상 symlink 아님을 감지 → removeCreated + expect(existsSync(join(root, 'link'))).toBe(false) + // outside 는 보존 + expect(existsSync(outside)).toBe(true) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + +// [#128-B2 P2-4] walk 내 non-sensitive symlink skip 도 스캔 예산에 포함 +// RED on pre-fix: skipped 가 maxFiles 무관하게 무한 증가하고 SCAN_CAPPED 가 안 찍힘. +// Windows: 'file' symlink 는 관리자 권한 필요 → 'junction'(디렉터리 링크) 으로 대체. +describe('[P2-4] walk 내 symlink skip 이 스캔 cap 예산에 포함된다', () => { + it('non-sensitive symlink 다수가 maxFiles 초과 시 skipped 가 bounded 되고 SCAN_CAPPED 가 찍힌다', async () => { + // 구조: links/ 디렉터리 아래 비민감 symlink 3개, maxFiles=1 + // pre-fix: 모든 링크가 skipped 에 push → skipped.length=3, SCAN_CAPPED 없음 + // post-fix: generalCount 소진 후 SCAN_CAPPED 기록, 이후 링크 skip 생략 + mkdirSync(join(root, 'links')) + const out1 = mkdtempSync(join(tmpdir(), 'fleet-p24a-')) + const out2 = mkdtempSync(join(tmpdir(), 'fleet-p24b-')) + const out3 = mkdtempSync(join(tmpdir(), 'fleet-p24c-')) + try { + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + symlinkSync(out1, join(root, 'links', 'lnk1'), linkType) + symlinkSync(out2, join(root, 'links', 'lnk2'), linkType) + symlinkSync(out3, join(root, 'links', 'lnk3'), linkType) + const git = fakeGitIgnored(['links/']) + const policy = { ...DEFAULT_IGNORED_POLICY, maxFiles: 1 } + const base = await captureIgnoredBaseline(root, git, policy) + + // SCAN_CAPPED が skipped に記録されていること(cap 到達の証拠) + expect(base.skipped.some((s) => s.path === SCAN_CAPPED && s.reason === 'over-cap')).toBe(true) + + // cap 이후 비민감 링크는 기록 생략 → skipped 총 건수(non-SCAN_CAPPED symlink) ≤ maxFiles + const symlinkSkips = base.skipped.filter((s) => s.reason === 'symlink') + expect(symlinkSkips.length).toBeLessThanOrEqual(policy.maxFiles) + } finally { + rmSync(out1, { recursive: true, force: true }) + rmSync(out2, { recursive: true, force: true }) + rmSync(out3, { recursive: true, force: true }) + } + }) + + it('sensitive symlink 은 cap 초과 후에도 반드시 기록된다(fail-closed)', async () => { + // 비민감 symlink 1개(cap 소진) → 이후 민감 symlink(.ssh → dir) 도 기록되어야 함 + // .env 는 file symlink(win32 에서 권한 필요) → .ssh(dir symlink)로 대체 + mkdirSync(join(root, 'links')) + const outNonSens = mkdtempSync(join(tmpdir(), 'fleet-p24ns-')) + const outSens = mkdtempSync(join(tmpdir(), 'fleet-p24s-')) + try { + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + // non-sensitive dir-symlink → cap 소진 + symlinkSync(outNonSens, join(root, 'links', 'lnk1'), linkType) + // sensitive dir-symlink(.ssh) → cap 후에도 항상 기록 + symlinkSync(outSens, join(root, '.ssh'), linkType) + // git 은 links/ 를 먼저 보고해 walk 에서 lnk1 처리, 그 다음 .ssh/ + const git = fakeGitIgnored(['links/', '.ssh/']) + const policy = { ...DEFAULT_IGNORED_POLICY, maxFiles: 1 } + // .ssh 는 sensitive → captureIgnoredBaseline 이 throw(fail-closed) + await expect(captureIgnoredBaseline(root, git, policy)).rejects.toThrow(/링크/) + } finally { + rmSync(outNonSens, { recursive: true, force: true }) + rmSync(outSens, { recursive: true, force: true }) + } + }) +}) + describe.skipIf(process.platform !== 'win32')( '[#128-B2] restore 쓰기 측 junction-guard (Windows)', () => { @@ -1050,5 +1142,34 @@ describe.skipIf(process.platform !== 'win32')( rmSync(outsideDir, { recursive: true, force: true }) } }) + + // [#128-B2 P2-1] baseline JUNCTION-to-dir → agent 가 실제 dir 로 치환 → restore 가 dir 제거 + // RED on pre-fix: git 은 child 만 보고하므로 'link' 자체가 created 루프에서 누락 → dir 잔존. + it('[P2-1 win32] baseline JUNCTION 을 실제 디렉터리로 치환하면 restore 가 그 디렉터리를 제거한다', async () => { + const outsideDir = mkdtempSync(join(tmpdir(), 'fleet-p21-')) + try { + // baseline: link 는 junction(디렉터리 가리킴) → skipped{symlink} + symlinkSync(outsideDir, join(root, 'link'), 'junction') + const baseGit = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + + // 에이전트가 junction 을 제거하고 실제 디렉터리(+자식 파일)로 치환 + rmSync(join(root, 'link'), { recursive: true, force: true }) + mkdirSync(join(root, 'link')) + writeFileSync(join(root, 'link', 'child.txt'), 'agent-child') + + // restore 시 git 은 'link/child.txt' 만 보고(link 자체는 디렉터리라 보고 안 함) + const curGit = fakeGitIgnored(['link/child.txt']) + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // [P2-1] baseline symlink sweep 이 link 가 더 이상 symlink 아님을 감지 → removeCreated + expect(existsSync(join(root, 'link'))).toBe(false) + // outside 는 보존 + expect(existsSync(outsideDir)).toBe(true) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + }) }, ) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index d849112..a5bd416 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -90,6 +90,23 @@ async function listIgnored( } files.push(key) } + // [#128-B2 P2-4] 링크 skip 도 스캔 예산에 포함(대량 링크 트리 unbounded 방지). + // 단 민감 경로는 항상 기록(fail-closed — capture 의 sensitive 검사가 s.path 를 본다). + const pushSkipSymlink = (rel: string): void => { + const key = rel.replace(/\\/g, '/') + const sensitive = policy.sensitiveRe.test(key) || policy.sensitiveRe.test(`${key}/`) + if (!sensitive) { + if (generalCount >= policy.maxFiles) { + if (!capped) { + capped = true + skipped.push({ path: SCAN_CAPPED, reason: 'over-cap' }) + } + return // 비민감 링크는 cap 초과 시 기록 생략(SCAN_CAPPED 가 불완전 신호) + } + generalCount++ + } + skipped.push({ path: key, reason: 'symlink' }) + } const walk = (relDir: string): void => { // early-terminate: cap 도달 후 서브디렉터리 탐색을 중단(unbounded traversal 방지) if (capped) return @@ -109,9 +126,33 @@ async function listIgnored( for (const ent of entries) { if (capped) return const rel = `${relDir}/${ent.name}` + // [#128-B2 P2-2] DT_UNKNOWN(network/FUSE) — dirent 종류 미상 → lstat 로 확정. + if (!ent.isSymbolicLink() && !ent.isDirectory() && !ent.isFile()) { + let est + try { + est = lstatSync(resolve(root, rel)) + } catch { + // lstat 실패(분류 불가) → fail-closed skip(symlink 동일 취급) + pushSkipSymlink(rel) + continue + } + if (est.isSymbolicLink()) { + pushSkipSymlink(rel) + continue + } + if (est.isDirectory()) { + const relSlash = `${rel}/` + if (policy.denylistRe.test(rel) || policy.denylistRe.test(relSlash)) continue + walk(rel) + continue + } + // 그 외(file/비정형) → pushFile(capture 의 lstat 가 regular/not-regular 처리) + pushFile(rel) + continue + } if (ent.isSymbolicLink()) { // [#128-B2] symlink/junction 은 따라가지 않는다 — 밖을 가리켜 읽거나 재귀하지 않음. - skipped.push({ path: rel.replace(/\\/g, '/'), reason: 'symlink' }) + pushSkipSymlink(rel) continue } if (ent.isDirectory()) { @@ -411,8 +452,9 @@ export async function restoreIgnoredBaseline( let st try { st = lstatSync(abs) - } catch { - return + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return // 이미 없음 — 삭제할 것 없음 + throw e // [#128-B2 P2-3] 분류 불가 created 경로 → fail-closed(restore 가 success 로 잔존 은폐 방지) } rmSync(abs, st.isSymbolicLink() ? { force: true } : { recursive: true, force: true }) } @@ -431,6 +473,20 @@ export async function restoreIgnoredBaseline( if (baseline.entries.has(s.path) || skippedPaths.has(s.path)) continue removeCreated(s.path) } + // [#128-B2 P2-1] baseline symlink 이 디렉터리를 가리켰고 agent 가 실제 dir/file 로 치환하면 + // git 은 child 만 보고하므로 위 created 루프가 'link' 자체를 못 지운다. + // 치환된(=더 이상 symlink 아닌) 것만 제거. 여전히 symlink 면 baseline 상태=그대로 둠. + for (const p of baselineSymlinkPaths) { + const abs = resolve(root, p) + let st + try { + st = lstatSync(abs) + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue + throw e + } + if (!st.isSymbolicLink()) removeCreated(p) // 여전히 symlink 면 baseline 상태=그대로 둠 + } // 2) backup 보유 엔트리 → 백업에서 복원(modified·deleted 모두 포함). for (const [path, entry] of baseline.entries) { if (entry.backup === null) continue // unrestorable — 복원 불가 From e8a193ade4bb8df48eafa5c50019befc87174528 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 18:34:59 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix(#128-B2):=20Codex=20=EC=9E=AC?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20round5=20P2=20=E2=80=94=20unclassified=20r?= =?UTF-8?q?eason=20=EB=B6=84=EB=A6=AC(=ED=8C=8C=EA=B4=B4=EC=A0=81=20sweep?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8)=C2=B7top-level=20skip=20cap=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=C2=B7collect=20symlink-=EC=B9=98=ED=99=98=20?= =?UTF-8?q?sweep=C2=B7symlink=E2=86=92symlink=20=ED=95=9C=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- ...026-06-23-issue128-B2-link-guard-design.md | 1 + .../core/workspace/ignored-baseline.test.ts | 153 +++++++++++++++++- src/main/core/workspace/ignored-baseline.ts | 100 ++++++++++-- 3 files changed, 233 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md b/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md index ad1fef6..6e9a155 100644 --- a/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md +++ b/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md @@ -95,6 +95,7 @@ export function escapesRoot(root: string, abs: string): boolean - TOCTOU 완전차단(순수 Node 불가·문서화). - 적대자 결정적 보장 · 순차→worktree 전환. - **finding5(`workspace.ignored_discarded` live emit)** — Codex #5 권장대로 **별도 PR로 분리**(B2는 보안 link-guard 핵심; live emit은 UX 표면화·`OrchestratorEventType` union 확장 별건). +- **symlink→symlink 치환 구분** — baseline symlink 가 다른 symlink 로 교체된 경우, target 을 읽지 않고는 구분 불가(no-follow/leak-zero 불변식 충돌). `readlink` 없이는 "원래 symlink 그대로" 인지 "에이전트 교체 symlink" 인지 알 수 없다. 따라서 그런 경우 rollback 이 에이전트 교체 symlink 를 남길 수 있음(advisory 비목표). 강한 격리는 OS/CLI 샌드박스(향후 B-tier) 로 이관. ## 영향 파일 diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 91dfca4..19f1fe3 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -580,14 +580,14 @@ describe.skipIf(process.platform !== 'win32')('[#128-B2] junction 비추종 (Win // ── Codex 3차 반영: [:96] [:109] [:244] [:270] ── describe('[:96] unreadable dir → fail-closed (lstat catch in top-level loop)', () => { - it('[Fix-B] lstat 실패 디렉터리는 walk 하지 않고 skipped{symlink} 로 fail-closed 기록된다', async () => { + it('[Fix-B] lstat 실패 디렉터리는 walk 하지 않고 skipped{unclassified} 로 fail-closed 기록된다', async () => { // 존재하지 않는 경로를 git status 로 반환 → lstat ENOENT → fail-closed(walk 안 함) - // Fix-B: lstat catch 시 skipped{path:dir, reason:'symlink'} 로 push, walk 금지. - // (ENOENT 포함 모든 lstat 실패를 동일하게 처리 — exotic reparse 방어 포함) + // [Codex round5 Fix-3] lstat 실패는 symlink 확정 아님 → 'unclassified' 로 기록. + // 이전에는 'symlink' 로 기록했으나, 이 경우 restore sweep 이 실제 디렉터리를 파괴하는 오동작이 발생. const git = fakeGitIgnored(['nonexistent-dir/']) const base = await captureIgnoredBaseline(root, git, DEFAULT_IGNORED_POLICY) - // fail-closed: skipped 에 'nonexistent-dir' 가 symlink 이유로 기록되어야 한다 - expect(base.skipped).toContainEqual({ path: 'nonexistent-dir', reason: 'symlink' }) + // fail-closed: skipped 에 'nonexistent-dir' 가 unclassified 이유로 기록되어야 한다 + expect(base.skipped).toContainEqual({ path: 'nonexistent-dir', reason: 'unclassified' }) // walk 하지 않았으므로 entries 는 비어 있어야 한다 expect(base.entries.size).toBe(0) }) @@ -1173,3 +1173,146 @@ describe.skipIf(process.platform !== 'win32')( }) }, ) + +// ── Codex round5 P2 픽스 검증 ── + +// [Codex round5 Fix-1] top-level symlink skip 도 스캔 cap 예산에 포함 +// RED on pre-fix: top-level loop 가 pushSkipSymlink 를 거치지 않고 직접 skipped.push → cap 우회. +// 주의: 이 테스트는 top-level(git-status 에서 직접 보고된 항목)의 symlink skip cap 을 검증한다. +// walk 내부(P2-4 테스트)와 별개이다. +describe('[round5 Fix-1] top-level symlink/unclassified skip 도 스캔 cap 에 포함된다', () => { + it('top-level 비민감 dir-symlink 다수가 maxFiles 초과 시 SCAN_CAPPED 기록 + skipped bounded', async () => { + // git 이 link1/, link2/, link3/ 를 top-level 에 직접 보고 → lstatSync → isSymbolicLink true + // maxFiles=1 이면 link1 하나만 소진 후 SCAN_CAPPED 기록, link2/link3 생략 + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + const out1 = mkdtempSync(join(tmpdir(), 'fleet-r5f1a-')) + const out2 = mkdtempSync(join(tmpdir(), 'fleet-r5f1b-')) + const out3 = mkdtempSync(join(tmpdir(), 'fleet-r5f1c-')) + try { + symlinkSync(out1, join(root, 'link1'), linkType) + symlinkSync(out2, join(root, 'link2'), linkType) + symlinkSync(out3, join(root, 'link3'), linkType) + // git 이 dir-symlink 를 trailing slash 포함 보고 + const git = fakeGitIgnored(['link1/', 'link2/', 'link3/']) + const policy = { ...DEFAULT_IGNORED_POLICY, maxFiles: 1 } + const base = await captureIgnoredBaseline(root, git, policy) + + // SCAN_CAPPED 기록 확인(cap 도달 증거) + expect(base.skipped.some((s) => s.path === SCAN_CAPPED && s.reason === 'over-cap')).toBe(true) + + // cap 이후 비민감 top-level symlink 는 생략 → symlink skips ≤ maxFiles + const symlinkSkips = base.skipped.filter((s) => s.reason === 'symlink') + expect(symlinkSkips.length).toBeLessThanOrEqual(policy.maxFiles) + } finally { + rmSync(out1, { recursive: true, force: true }) + rmSync(out2, { recursive: true, force: true }) + rmSync(out3, { recursive: true, force: true }) + } + }) + + it('top-level cap 초과 후에도 민감 symlink 는 반드시 기록된다(fail-closed)', async () => { + // 비민감 link1 이 cap 소진 → 이후 민감 .ssh symlink 도 기록되어야 함 + // → captureIgnoredBaseline 이 throw(fail-closed) + const linkType = process.platform === 'win32' ? 'junction' : 'dir' + const outNonSens = mkdtempSync(join(tmpdir(), 'fleet-r5f1ns-')) + const outSens = mkdtempSync(join(tmpdir(), 'fleet-r5f1s-')) + try { + symlinkSync(outNonSens, join(root, 'link1'), linkType) + symlinkSync(outSens, join(root, '.ssh'), linkType) + const git = fakeGitIgnored(['link1/', '.ssh/']) + const policy = { ...DEFAULT_IGNORED_POLICY, maxFiles: 1 } + await expect(captureIgnoredBaseline(root, git, policy)).rejects.toThrow(/링크/) + } finally { + rmSync(outNonSens, { recursive: true, force: true }) + rmSync(outSens, { recursive: true, force: true }) + } + }) +}) + +// [Codex round5 Fix-4] baseline symlink-to-dir 이 EMPTY 실디렉터리로 치환 → collect 탐지 +// RED on pre-fix: git 은 자식 없는 빈 디렉터리를 보고하지 않아 files 루프에서 탐지 불가. +describe.skipIf(process.platform === 'win32')( + '[round5 Fix-4] baseline symlink-to-dir 을 빈 실디렉터리로 치환하면 collect 가 created 로 탐지 (POSIX)', + () => { + it('baseline symlink → empty real dir 치환 → collectIgnoredChanges reports created', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-r5f4-')) + try { + // baseline: link 는 symlink-to-dir → skipped{symlink} + symlinkSync(outside, join(root, 'link'), 'dir') + const baseGit = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + + // 에이전트가 symlink 를 제거하고 빈 실디렉터리로 치환 + rmSync(join(root, 'link'), { force: true }) + mkdirSync(join(root, 'link')) + // 빈 디렉터리 → git 은 아무 자식도 보고 안 함 + const curGit = fakeGitIgnored([]) + const cs = await collectIgnoredChanges(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // baselineSymlinkPaths sweep 이 link 를 탐지해 created 로 보고해야 한다 + expect(cs.changes.some((c) => c.path === 'link' && c.change === 'created')).toBe(true) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + +describe.skipIf(process.platform !== 'win32')( + '[round5 Fix-4] baseline JUNCTION-to-dir 을 빈 실디렉터리로 치환하면 collect 가 created 로 탐지 (win32)', + () => { + it('baseline junction → empty real dir 치환 → collectIgnoredChanges reports created', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-r5f4w-')) + try { + // baseline: link 는 junction → skipped{symlink} + symlinkSync(outside, join(root, 'link'), 'junction') + const baseGit = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + + // 에이전트가 junction 을 제거하고 빈 실디렉터리로 치환 + rmSync(join(root, 'link'), { recursive: true, force: true }) + mkdirSync(join(root, 'link')) + // 빈 디렉터리 → git 은 아무 자식도 보고 안 함 + const curGit = fakeGitIgnored([]) + const cs = await collectIgnoredChanges(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // baselineSymlinkPaths sweep 이 link 를 탐지해 created 로 보고해야 한다 + expect(cs.changes.some((c) => c.path === 'link' && c.change === 'created')).toBe(true) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + +// [Codex round5 Fix-3] baseline skipped{unclassified} 는 restore sweep 에서 건드리지 않는다 +// RED on pre-fix: lstat-fail 이 'symlink' 로 기록됐을 때 restore sweep(baselineSymlinkPaths)이 +// 해당 경로를 "치환됨"으로 판단해 removeCreated → 실제 디렉터리 파괴. +// post-fix: 'unclassified' 는 baselineSymlinkPaths 에서 제외 → sweep 건너뜀 → 디렉터리 보존. +describe('[round5 Fix-3] baseline skipped{unclassified} 는 restore sweep 에서 삭제하지 않는다', () => { + it('unclassified 엔트리가 있는 경로가 실제 디렉터리로 존재해도 restore sweep 이 삭제하지 않는다', async () => { + // 실 lstat-fail 은 portable 하게 재현 불가(exotic reparse 등) → 수동 baseline 주입으로 검증. + // "lstat 실패였던" 경로가 실제로는 디렉터리인 경우를 시뮬레이션. + // RED 조건: 이전에 'symlink' 로 기록되면 sweep 이 lstatSync → !isSymbolicLink → removeCreated + // → 디렉터리 삭제. post-fix 는 'unclassified' 이므로 baselineSymlinkPaths 에 없음 → sweep 제외. + const baseGit = fakeGitIgnored([]) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + + // 수동으로 unclassified 엔트리 주입(실 lstat-fail 시뮬레이션) + base.skipped.push({ path: 'real-dir', reason: 'unclassified' }) + + // 실제 디렉터리 생성 + mkdirSync(join(root, 'real-dir')) + + // restore 시 git 은 real-dir/ 를 디렉터리로 보고 — 내용이 없으므로 아무 파일도 보고 안 함. + // real-dir 자체는 skippedPaths 에 있으므로 files 루프에서도 제외됨. + // 핵심 검증: baselineSymlinkPaths sweep 이 real-dir 을 삭제하지 않아야 한다. + const curGit = fakeGitIgnored([]) + await restoreIgnoredBaseline(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // unclassified → baselineSymlinkPaths 에 없음 → sweep 건너뜀 → 디렉터리 보존 + expect(existsSync(join(root, 'real-dir'))).toBe(true) + }) +}) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index a5bd416..45bc55d 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -46,7 +46,14 @@ export interface IgnoredEntry { } export interface IgnoredBaseline { entries: Map - skipped: { path: string; reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' }[] + skipped: { + path: string + // [Codex round5 Fix-3] 'unclassified': lstat 자체가 실패해 symlink 여부를 확정할 수 없는 경우. + // 'symlink': lstatSync().isSymbolicLink() === true 로 확정된 경우에만 사용. + // 이 구분이 없으면 restore sweep 이 lstat-fail 경로(실제 디렉터리일 수 있음)를 삭제해 + // 실제 디렉터리를 파괴하는 파괴적 오동작이 발생한다. + reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' | 'unclassified' + }[] } // git status --ignored 로 in-scope ignored 파일을 열거한다. @@ -62,14 +69,17 @@ async function listIgnored( root: string, git: GitRunner, policy: ScanPolicy, -): Promise<{ files: string[]; skipped: { path: string; reason: 'over-cap' | 'symlink' }[] }> { +): Promise<{ + files: string[] + skipped: { path: string; reason: 'over-cap' | 'symlink' | 'unclassified' }[] +}> { const r = await git.run(['status', '--ignored', '--porcelain=v1', '-z'], root) // [P2-3] git status 실패는 hard-fail if (r.code !== 0) throw new Error('git status --ignored 실패: ' + r.stderr.trim()) const records = r.stdout.split('\0').filter(Boolean) const ignored = records.filter((rec) => rec.startsWith('!! ')).map((rec) => rec.slice(3)) const files: string[] = [] - const skipped: { path: string; reason: 'over-cap' | 'symlink' }[] = [] + const skipped: { path: string; reason: 'over-cap' | 'symlink' | 'unclassified' }[] = [] let generalCount = 0 // capped: generalCount >= maxFiles に達した後は true。walk は冒頭で確認し即 return する。 let capped = false @@ -90,9 +100,11 @@ async function listIgnored( } files.push(key) } - // [#128-B2 P2-4] 링크 skip 도 스캔 예산에 포함(대량 링크 트리 unbounded 방지). + // [#128-B2 P2-4 + Codex round5 Fix-1] 링크/분류불가 skip 도 스캔 예산에 포함. + // walk 내부 AND top-level 루프 양쪽에서 모든 symlink/unclassified skip 을 이 helper 로 경유시켜 + // top-level 에서 cap 을 우회하는 문제를 차단한다. // 단 민감 경로는 항상 기록(fail-closed — capture 의 sensitive 검사가 s.path 를 본다). - const pushSkipSymlink = (rel: string): void => { + const pushSkip = (rel: string, reason: 'symlink' | 'unclassified'): void => { const key = rel.replace(/\\/g, '/') const sensitive = policy.sensitiveRe.test(key) || policy.sensitiveRe.test(`${key}/`) if (!sensitive) { @@ -101,11 +113,11 @@ async function listIgnored( capped = true skipped.push({ path: SCAN_CAPPED, reason: 'over-cap' }) } - return // 비민감 링크는 cap 초과 시 기록 생략(SCAN_CAPPED 가 불완전 신호) + return // 비민감 링크/미분류는 cap 초과 시 생략(SCAN_CAPPED 가 불완전 신호) } generalCount++ } - skipped.push({ path: key, reason: 'symlink' }) + skipped.push({ path: key, reason }) } const walk = (relDir: string): void => { // early-terminate: cap 도달 후 서브디렉터리 탐색을 중단(unbounded traversal 방지) @@ -132,12 +144,14 @@ async function listIgnored( try { est = lstatSync(resolve(root, rel)) } catch { - // lstat 실패(분류 불가) → fail-closed skip(symlink 동일 취급) - pushSkipSymlink(rel) + // [Codex round5 Fix-3] lstat 실패 = 분류 불가(exotic reparse 등) → 'unclassified' 로 기록. + // 'symlink' 가 아니므로 restore sweep 의 baselineSymlinkPaths 에 포함되지 않아 + // 실제 디렉터리가 있는 경우 파괴적 삭제를 방지한다(fail-closed skip). + pushSkip(rel, 'unclassified') continue } if (est.isSymbolicLink()) { - pushSkipSymlink(rel) + pushSkip(rel, 'symlink') continue } if (est.isDirectory()) { @@ -152,7 +166,7 @@ async function listIgnored( } if (ent.isSymbolicLink()) { // [#128-B2] symlink/junction 은 따라가지 않는다 — 밖을 가리켜 읽거나 재귀하지 않음. - pushSkipSymlink(rel) + pushSkip(rel, 'symlink') continue } if (ent.isDirectory()) { @@ -182,13 +196,16 @@ async function listIgnored( try { dirSt = lstatSync(resolve(root, dir)) } catch { - // [Codex P1 Fix-B] lstat 분류 실패(exotic reparse 등) → walk 하면 readdirSync 가 target 을 열거해 밖 탈출. + // [Codex P1 Fix-B + round5 Fix-3] lstat 분류 실패(exotic reparse 등) → walk 하면 readdirSync 가 target 을 열거해 밖 탈출. // fail-closed: skip(표면화), walk 안 함. (ENOENT 면 어차피 수집할 것 없음.) - skipped.push({ path: dir, reason: 'symlink' }) + // [Codex round5 Fix-1] top-level lstat-fail 도 pushSkip 경유해 cap 카운트에 포함. + // [Codex round5 Fix-3] lstat 실패는 symlink 확정 아님 → 'unclassified' 로 기록. + pushSkip(dir, 'unclassified') continue } if (dirSt.isSymbolicLink()) { - skipped.push({ path: dir, reason: 'symlink' }) + // [Codex round5 Fix-1] top-level confirmed-symlink 도 pushSkip 경유해 cap 카운트에 포함. + pushSkip(dir, 'symlink') continue } walk(dir) @@ -202,7 +219,8 @@ async function listIgnored( continue } if (fileSt.isSymbolicLink()) { - skipped.push({ path: rel, reason: 'symlink' }) + // [Codex round5 Fix-1] top-level file-symlink 도 pushSkip 경유해 cap 카운트에 포함. + pushSkip(rel, 'symlink') continue } pushFile(rel) @@ -225,6 +243,8 @@ export async function captureIgnoredBaseline( // 따라서 s.path='.ssh' 는 test false → fail-open 위험. // 두 형태 모두 검사: s.path(파일형) AND `${s.path}/`(디렉터리형, slash 복원)로 // 슬래시가 제거된 디렉터리 심볼릭 링크도 확실히 차단한다. + // [Codex round5 Fix-3] reason 필터 없음 — symlink AND unclassified 경로 모두 검사. + // 분류 불가(unclassified) 경로도 민감하면 fail-closed(안전상 처리 불가). for (const s of enumSkipped) { if (s.path === SCAN_CAPPED) continue // 합성 over-cap 마커는 실경로 아님 // listIgnored 가 분류 못 한(링크/분류불가) 경로는 capture 가 안전 백업 불가 → 민감하면 fail-closed. @@ -236,7 +256,7 @@ export async function captureIgnoredBaseline( const entries = new Map() const skipped: { path: string - reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' + reason: 'over-cap' | 'read-failed' | 'not-regular' | 'symlink' | 'unclassified' }[] = [...enumSkipped] let totalBytes = 0 try { @@ -317,10 +337,14 @@ export async function collectIgnoredChanges( // [P2-4] capture current-scan skipped too const { files, skipped: currentSkipped } = await listIgnored(root, git, policy) const skippedPaths = new Set(baseline.skipped.map((s) => s.path)) + // [Codex round5 Fix-3] baselineSymlinkPaths = reason==='symlink' 만 — 'unclassified' 제외. + // 'unclassified' 는 lstat-fail 로 실제 타입 불명. restore sweep 에서 확정 non-symlink 인지 + // 알 수 없으므로 삭제 대상에서 제외해야 한다(파괴적 오동작 방지). const baselineSymlinkPaths = new Set( baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), ) const changes: IgnoredChange[] = [] + const changedPaths = new Set() // [P2-4] merge baseline.skipped + currentSkipped, deduplicate by path const seenUnrestorable = new Set(baseline.skipped.map((s) => s.path)) const unrestorable: { path: string; reason: string }[] = [...baseline.skipped] @@ -338,6 +362,7 @@ export async function collectIgnoredChanges( if (baseline.entries.has(path) || (skippedPaths.has(path) && !baselineSymlinkPaths.has(path))) continue changes.push({ path, change: 'created', sensitive: policy.sensitiveRe.test(path) }) + changedPaths.add(path) } // modified / deleted: baseline 엔트리 기준. // [:244] 현재 파일 해싱 시 누적 바이트 추적 — maxTotalBytes 초과 시 read 없이 modified+unrestorable @@ -346,6 +371,7 @@ export async function collectIgnoredChanges( const abs = resolve(root, path) if (!existsSync(abs)) { changes.push({ path, change: 'deleted', sensitive: entry.sensitive }) + changedPaths.add(path) if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) continue } @@ -355,6 +381,7 @@ export async function collectIgnoredChanges( st = lstatSync(abs) // [#128-B2] 링크 비추종 } catch { changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + changedPaths.add(path) unrestorable.push({ path, reason: 'stat-failed' }) continue } @@ -362,6 +389,7 @@ export async function collectIgnoredChanges( // [#128-B2] baseline 일반파일이 링크로 교체됨 = modified. read 안 함(밖 유출 차단). // backup 있으면 restore 가 링크 제거 후 복원 → unrestorable 아님. changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + changedPaths.add(path) if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) continue } @@ -369,18 +397,21 @@ export async function collectIgnoredChanges( // baseline 일반 파일이 non-regular 로 교체됨 = modified. read 없이(hang 방지). // backup 있으면 restore 가 비-일반 leaf 제거 후 복원 → unrestorable 아님. changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + changedPaths.add(path) if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) continue } const currentSize = st.size if (currentSize > policy.maxFileBytes) { changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + changedPaths.add(path) unrestorable.push({ path, reason: 'over-cap-modified' }) continue } // [:244] 누적 cap 초과 → read 없이 modified+unrestorable(over-cap) if (collectTotalBytes + currentSize > policy.maxTotalBytes) { changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + changedPaths.add(path) unrestorable.push({ path, reason: 'over-cap-modified' }) continue } @@ -389,9 +420,37 @@ export async function collectIgnoredChanges( const hash = createHash('sha256').update(buf).digest('hex') if (hash !== entry.hash) { changes.push({ path, change: 'modified', sensitive: entry.sensitive }) + changedPaths.add(path) if (entry.backup === null) unrestorable.push({ path, reason: 'no-backup' }) } } + // [Codex round5 Fix-4] baseline symlink-to-dir 이 EMPTY 실디렉터리로 치환된 경우 + // git 은 자식 파일이 없으면 dir 자체만 보고하지 않아 위 files 루프에서 탐지 불가. + // baselineSymlinkPaths 를 직접 순회해 추가 감지한다. + // 주의: symlink→symlink 치환은 target 을 읽지 않고는 구분 불가(no-follow/leak-zero 불변식) + // → advisory 비목표. 그런 경우 rollback 이 agent 교체 symlink 를 남길 수 있음. + for (const path of baselineSymlinkPaths) { + if (changedPaths.has(path)) continue // 이미 위 루프에서 탐지됨 + const abs = resolve(root, path) + let st + try { + st = lstatSync(abs) + } catch (e) { + const code = (e as NodeJS.ErrnoException).code + if (code === 'ENOENT') continue // 사라짐 — 위 entries 루프가 다룸(entries 에 없으면 변경 없음) + // lstat 실패 → stat 불가(unrestorable) + unrestorable.push({ path, reason: 'stat-failed' }) + continue + } + if (st.isSymbolicLink()) continue // 여전히 symlink → baseline 상태 유지, 변경 없음 + // 더 이상 symlink 가 아님 = 에이전트가 실디렉터리(또는 파일)로 치환 → created 로 표면화 + changes.push({ + path, + change: 'created', + sensitive: policy.sensitiveRe.test(path) || policy.sensitiveRe.test(`${path}/`), + }) + changedPaths.add(path) + } return { changes, unrestorable } } @@ -438,6 +497,10 @@ export async function restoreIgnoredBaseline( // 이번 삭제 패스에서 누락될 수 있음 → rollback 불완전 가능성 표면화). const capped = skipped.some((s) => s.path === SCAN_CAPPED && s.reason === 'over-cap') const skippedPaths = new Set(baseline.skipped.map((s) => s.path)) + // [Codex round5 Fix-3] baselineSymlinkPaths = reason==='symlink' 만 — 'unclassified' 제외. + // restore sweep 은 "symlink 였는데 지금 non-symlink = 치환됨"을 판정한다. + // 'unclassified' 는 baseline 시점 타입 불명이므로 sweep 대상에서 제외해야 한다. + // 'unclassified' 경로를 sweep 에 포함하면 실제 디렉터리를 파괴하는 오동작이 발생한다. const baselineSymlinkPaths = new Set( baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), ) @@ -476,6 +539,11 @@ export async function restoreIgnoredBaseline( // [#128-B2 P2-1] baseline symlink 이 디렉터리를 가리켰고 agent 가 실제 dir/file 로 치환하면 // git 은 child 만 보고하므로 위 created 루프가 'link' 자체를 못 지운다. // 치환된(=더 이상 symlink 아닌) 것만 제거. 여전히 symlink 면 baseline 상태=그대로 둠. + // [Codex round5 Fix-3] baselineSymlinkPaths 에는 'symlink' reason 만 포함(위 선언 참조). + // 'unclassified' 는 baseline 시점 타입 불명 → sweep 제외(실 디렉터리 파괴 방지). + // [Codex round5 Fix-2] Advisory 비목표: symlink→symlink 치환은 target 을 읽지 않고는 + // 구분 불가(no-follow/leak-zero 불변식 충돌). 그런 경우 rollback 이 agent 교체 symlink 를 + // 남길 수 있음. 강한 격리는 OS/CLI 샌드박스(향후 B-tier) 로 이관. for (const p of baselineSymlinkPaths) { const abs = resolve(root, p) let st From baa3f932cd6dcc03e57db5eb222ac8c3599dd5f6 Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 19:08:23 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix(#128-B2):=20Codex=20=EC=9E=AC?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20round6=20P2=20=E2=80=94=20=EB=AF=BC?= =?UTF-8?q?=EA=B0=90=20=EB=A7=81=ED=81=AC=20cap-=ED=9B=84=20fail-closed=20?= =?UTF-8?q?=EB=8F=84=EB=8B=AC=20=EB=B3=B4=EC=9E=A5=C2=B7=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=9C=20baseline=20symlink=20deleted=20=ED=91=9C=EB=A9=B4?= =?UTF-8?q?=ED=99=94=C2=B7unclassified=20whitelist=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- ...026-06-23-issue128-B2-link-guard-design.md | 17 +++ .../core/workspace/ignored-baseline.test.ts | 106 ++++++++++++++++++ src/main/core/workspace/ignored-baseline.ts | 34 +++++- 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md b/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md index 6e9a155..0435594 100644 --- a/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md +++ b/docs/superpowers/specs/2026-06-23-issue128-B2-link-guard-design.md @@ -100,3 +100,20 @@ export function escapesRoot(root: string, abs: string): boolean ## 영향 파일 `src/main/core/workspace/path-guard.ts`(신규+test) · `ignored-baseline.ts`(+test) · `workspace-tools.ts`(+test) · (필요 시 `git.ts` `samePath`를 helper로 흡수 검토). 신규 win32/POSIX 분기 테스트. + +--- + +## 한계 및 보수적 설계 결정 (Codex round6 Fix-3 추가) + +### unclassified(lstat-fail) baseline 경로 — whitelist 유지 근거 + +`baseline.skipped` 의 `reason === 'unclassified'` 경로는 baseline 수집 시점에 `lstatSync` 가 실패해 **실제 타입(symlink인지 dir인지 file인지)을 확정할 수 없는** 경로다. + +이 경로를 `baselineSymlinkPaths`(collect/restore sweep 대상)에 포함시키지 않는 이유: + +- lstat 실패는 exotic reparse point(OneDrive, AppExecLink 등)·권한 오류·ENOENT 등 여러 원인으로 발생한다. +- **만약 그 자리에 pre-existing 파일/디렉터리가 있었을 경우**, sweep 이 "치환됨"으로 판단해 `removeCreated` → 강제 삭제하면 **baseline 이전부터 존재하던 데이터를 파괴**한다. +- 이는 Codex round5 에서 실제로 발생한 파괴적 버그(lstat-fail 경로를 `symlink` 로 잘못 분류 → sweep 이 실 디렉터리를 삭제)와 **동일한 클래스의 위험**이다. +- 따라서 `'unclassified'` 는 `'read-failed'`·`'over-cap'` 과 동일하게 **보수적 whitelist** 로 취급: rollback 강제 삭제 대상에서 제외하고, `unrestorable` 로 표면화해 호출자(오케스트레이터)가 수동 판단할 수 있게 한다. + +결론: `baselineSymlinkPaths` 는 `reason === 'symlink'` 로 **확정된** 경로만 포함한다. 이것이 안전하게 "치환 감지 후 삭제"를 수행할 수 있다고 증명된 유일한 케이스다. diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 19f1fe3..5af0415 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -1316,3 +1316,109 @@ describe('[round5 Fix-3] baseline skipped{unclassified} 는 restore sweep 에서 expect(existsSync(join(root, 'real-dir'))).toBe(true) }) }) + +// ── Codex round6 Fix-1: baseline symlink 삭제 → deleted 표면화 ── +// RED on pre-fix: baselineSymlinkPaths sweep ENOENT 분기가 `continue` → deleted 보고 없음. +// post-fix: ENOENT 시 `changes.push({change:'deleted',...})` → 표면화됨. +describe.skipIf(process.platform === 'win32')( + '[round6 Fix-1] baseline symlink-dir DELETED → collectIgnoredChanges reports deleted (POSIX)', + () => { + it('baseline symlink(POSIX dir) 이 삭제되면 collectIgnoredChanges 가 deleted 로 보고한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-r6f1-')) + try { + // baseline: link → symlink-to-dir → capture 가 skipped{symlink} + symlinkSync(outside, join(root, 'link'), 'dir') + const baseGit = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + + // 에이전트가 symlink 를 삭제 — 아무것도 남기지 않음 + rmSync(join(root, 'link'), { force: true }) + + // collect: git 은 아무것도 보고 안 함(symlink 도 대체물도 없음) + const curGit = fakeGitIgnored([]) + const cs = await collectIgnoredChanges(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // RED: pre-fix 는 ENOENT → continue → changes 에 아무것도 없음. + // GREEN: post-fix 는 deleted 를 push. + expect(cs.changes.some((c) => c.path === 'link' && c.change === 'deleted')).toBe(true) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + +describe.skipIf(process.platform !== 'win32')( + '[round6 Fix-1] baseline junction DELETED → collectIgnoredChanges reports deleted (win32)', + () => { + it('baseline junction 이 삭제되면 collectIgnoredChanges 가 deleted 로 보고한다', async () => { + const outside = mkdtempSync(join(tmpdir(), 'fleet-r6f1w-')) + try { + // baseline: link → junction → capture 가 skipped{symlink} + symlinkSync(outside, join(root, 'link'), 'junction') + const baseGit = fakeGitIgnored(['link/']) + const base = await captureIgnoredBaseline(root, baseGit, DEFAULT_IGNORED_POLICY) + expect(base.skipped).toContainEqual({ path: 'link', reason: 'symlink' }) + + // 에이전트가 junction 을 삭제 + rmSync(join(root, 'link'), { recursive: true, force: true }) + + // collect: git 은 아무것도 보고 안 함 + const curGit = fakeGitIgnored([]) + const cs = await collectIgnoredChanges(root, curGit, base, DEFAULT_IGNORED_POLICY) + + // RED: pre-fix 는 ENOENT → continue → changes 에 아무것도 없음. + // GREEN: post-fix 는 deleted 를 push. + expect(cs.changes.some((c) => c.path === 'link' && c.change === 'deleted')).toBe(true) + } finally { + rmSync(outside, { recursive: true, force: true }) + } + }) + }, +) + +// ── Codex round6 Fix-2: cap 후 sensitive symlink 가 fail-closed 에 도달하는지 검증 ── +// RED on pre-fix: top-level `if (capped) break` が非民感リンクで cap をトリップ後に break → +// 後続の .ssh/ レコードが captureIgnoredBaseline の sensitive 検査に到達しない(fail-OPEN). +// post-fix: break 削除 → .ssh/ がループを通過 → pushSkip(sensitive) → throw(fail-CLOSED). +describe('[round6 Fix-2] cap 후 sensitive symlink 가 top-level loop 를 통과해 fail-closed 에 도달한다', () => { + it('POSIX: non-sensitive link cap 후 .ssh symlink → captureIgnoredBaseline throws', async () => { + if (process.platform === 'win32') return // junction variant below + const outNonSens = mkdtempSync(join(tmpdir(), 'fleet-r6f2a-')) + const outSens = mkdtempSync(join(tmpdir(), 'fleet-r6f2b-')) + try { + // link1, link2 = non-sensitive dir-symlinks (maxFiles=1 → link1 이 cap 소진) + symlinkSync(outNonSens, join(root, 'link1'), 'dir') + symlinkSync(outNonSens, join(root, 'link2'), 'dir') + // .ssh = sensitive dir-symlink — cap 이후 레코ードとして続く + symlinkSync(outSens, join(root, '.ssh'), 'dir') + // git 은 link1/ → link2/ → .ssh/ 순서로 보고 + const git = fakeGitIgnored(['link1/', 'link2/', '.ssh/']) + const policy = { ...DEFAULT_IGNORED_POLICY, maxFiles: 1 } + // RED: pre-fix は break により .ssh/ に到達せず → resolve(no throw). + // GREEN: post-fix は break なし → .ssh/ が pushSkip(sensitive) → captureIgnoredBaseline が throw. + await expect(captureIgnoredBaseline(root, git, policy)).rejects.toThrow(/링크/) + } finally { + rmSync(outNonSens, { recursive: true, force: true }) + rmSync(outSens, { recursive: true, force: true }) + } + }) + + it('win32: non-sensitive junction cap 후 .ssh junction → captureIgnoredBaseline throws', async () => { + if (process.platform !== 'win32') return + const outNonSens = mkdtempSync(join(tmpdir(), 'fleet-r6f2c-')) + const outSens = mkdtempSync(join(tmpdir(), 'fleet-r6f2d-')) + try { + symlinkSync(outNonSens, join(root, 'link1'), 'junction') + symlinkSync(outNonSens, join(root, 'link2'), 'junction') + symlinkSync(outSens, join(root, '.ssh'), 'junction') + const git = fakeGitIgnored(['link1/', 'link2/', '.ssh/']) + const policy = { ...DEFAULT_IGNORED_POLICY, maxFiles: 1 } + await expect(captureIgnoredBaseline(root, git, policy)).rejects.toThrow(/링크/) + } finally { + rmSync(outNonSens, { recursive: true, force: true }) + rmSync(outSens, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index 45bc55d..f5ff758 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -183,8 +183,14 @@ async function listIgnored( pushFile(rel) } } + // [Codex round6 Fix-2] top-level `if (capped) break` を削除。 + // git が報告するレコードリストは有限なので短絡は不要だが、安全上危険: + // 非民感リンクが cap をトリップした後に SENSITIVE レコード(.ssh/ など)が続く場合、 + // break すると sensitive skip が captureIgnoredBaseline の fail-closed 検査に到達しない + // (fail-OPEN になる)。 + // walk 内部の `if (capped) return` ガードはそのまま残す — unbounded traversal 防止のため必須。 + // cap 後に dir レコードが来ても walk(dir) は冒頭で即 return(bounded)なので余分な作業は発生しない。 for (const e of ignored) { - if (capped) break const rel = e.replace(/\\/g, '/') if (rel.endsWith('/')) { const dir = rel.replace(/\/+$/, '') @@ -340,6 +346,12 @@ export async function collectIgnoredChanges( // [Codex round5 Fix-3] baselineSymlinkPaths = reason==='symlink' 만 — 'unclassified' 제외. // 'unclassified' 는 lstat-fail 로 실제 타입 불명. restore sweep 에서 확정 non-symlink 인지 // 알 수 없으므로 삭제 대상에서 제외해야 한다(파괴적 오동작 방지). + // [Codex round6 Fix-3 — DOCUMENT] 'unclassified' を created-whitelist から外さない理由: + // lstat が失敗した経路は「空のディレクトリだったのか、pre-existing なファイルだったのか」判定不能。 + // agent が後でそこにファイルを作った場合、強制削除すると pre-existing データを破壊する可能性がある。 + // これは round5 で起きた破壊的バグ(sweep が実ディレクトリを削除)と同クラスのリスク。 + // よって 'unclassified' は 'read-failed'/'over-cap' と同様に保守的 whitelist(強制 rollback しない)扱いとし、 + // unrestorable として表面化する(collect 側)。 const baselineSymlinkPaths = new Set( baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), ) @@ -437,7 +449,20 @@ export async function collectIgnoredChanges( st = lstatSync(abs) } catch (e) { const code = (e as NodeJS.ErrnoException).code - if (code === 'ENOENT') continue // 사라짐 — 위 entries 루프가 다룸(entries 에 없으면 변경 없음) + if (code === 'ENOENT') { + // [Codex round6 Fix-1] baseline symlink 이 삭제됨 → deleted 변경으로 표면화. + // 이전 코드는 `continue` 했는데, baseline.entries 에 없는 symlink 경로는 + // 위 entries 루프가 처리하지 않으므로 deleted 가 영원히 보고되지 않는다(누락 버그). + // restore 는 symlink 를 재생성할 수 없으므로(backup 없음) restore 변경은 없음. + // de-dup: changedPaths 에 없는 경우만 push. + if (!changedPaths.has(path)) + changes.push({ + path, + change: 'deleted', + sensitive: policy.sensitiveRe.test(path) || policy.sensitiveRe.test(`${path}/`), + }) + continue + } // lstat 실패 → stat 불가(unrestorable) unrestorable.push({ path, reason: 'stat-failed' }) continue @@ -501,6 +526,11 @@ export async function restoreIgnoredBaseline( // restore sweep 은 "symlink 였는데 지금 non-symlink = 치환됨"을 판정한다. // 'unclassified' 는 baseline 시점 타입 불명이므로 sweep 대상에서 제외해야 한다. // 'unclassified' 경로를 sweep 에 포함하면 실제 디렉터리를 파괴하는 오동작이 발생한다. + // [Codex round6 Fix-3 — DOCUMENT] 'unclassified' を whitelist から外して強制 rollback しない理由: + // lstat-fail の経路は baseline 時点でのタイプ(symlink/dir/file)が確定できない。 + // 強制削除すると pre-existing なファイル/ディレクトリを破壊するリスクがある(round5 破壊的バグと同クラス)。 + // 'read-failed'/'over-cap' と同様に保守的 whitelist とし、unrestorable として表面化する。 + // 確定した symlink('symlink' reason)のみが sweep 対象になる — これが安全に削除できると証明された唯一のケース。 const baselineSymlinkPaths = new Set( baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), ) From bc10b542dbef9a0d610cf8a9df5addd337a01c1f Mon Sep 17 00:00:00 2001 From: Dowon Park Date: Tue, 23 Jun 2026 19:16:24 +0900 Subject: [PATCH 19/19] =?UTF-8?q?style(#128-B2):=20round6=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=A3=BC=EC=84=9D=20=EC=9D=BC=EB=B3=B8=EC=96=B4?= =?UTF-8?q?=E2=86=92=ED=95=9C=EA=B5=AD=EC=96=B4=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94(=EC=BD=94=EB=93=9C=EB=B2=A0=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011LksLaP3AYvAtbcoKkatwF --- .../core/workspace/ignored-baseline.test.ts | 6 ++-- src/main/core/workspace/ignored-baseline.ts | 35 +++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/main/core/workspace/ignored-baseline.test.ts b/src/main/core/workspace/ignored-baseline.test.ts index 5af0415..a2ac5f8 100644 --- a/src/main/core/workspace/ignored-baseline.test.ts +++ b/src/main/core/workspace/ignored-baseline.test.ts @@ -1379,9 +1379,9 @@ describe.skipIf(process.platform !== 'win32')( ) // ── Codex round6 Fix-2: cap 후 sensitive symlink 가 fail-closed 에 도달하는지 검증 ── -// RED on pre-fix: top-level `if (capped) break` が非民感リンクで cap をトリップ後に break → -// 後続の .ssh/ レコードが captureIgnoredBaseline の sensitive 検査に到達しない(fail-OPEN). -// post-fix: break 削除 → .ssh/ がループを通過 → pushSkip(sensitive) → throw(fail-CLOSED). +// RED on pre-fix: top-level `if (capped) break` 가 비민감 링크로 cap 트립 후 break → +// 후속 .ssh/ 레코드가 captureIgnoredBaseline 의 sensitive 검사에 도달하지 못함(fail-OPEN). +// post-fix: break 제거 → .ssh/ 가 루프를 통과 → pushSkip(sensitive) → throw(fail-CLOSED). describe('[round6 Fix-2] cap 후 sensitive symlink 가 top-level loop 를 통과해 fail-closed 에 도달한다', () => { it('POSIX: non-sensitive link cap 후 .ssh symlink → captureIgnoredBaseline throws', async () => { if (process.platform === 'win32') return // junction variant below diff --git a/src/main/core/workspace/ignored-baseline.ts b/src/main/core/workspace/ignored-baseline.ts index f5ff758..5458575 100644 --- a/src/main/core/workspace/ignored-baseline.ts +++ b/src/main/core/workspace/ignored-baseline.ts @@ -183,13 +183,12 @@ async function listIgnored( pushFile(rel) } } - // [Codex round6 Fix-2] top-level `if (capped) break` を削除。 - // git が報告するレコードリストは有限なので短絡は不要だが、安全上危険: - // 非民感リンクが cap をトリップした後に SENSITIVE レコード(.ssh/ など)が続く場合、 - // break すると sensitive skip が captureIgnoredBaseline の fail-closed 検査に到達しない - // (fail-OPEN になる)。 - // walk 内部の `if (capped) return` ガードはそのまま残す — unbounded traversal 防止のため必須。 - // cap 後に dir レコードが来ても walk(dir) は冒頭で即 return(bounded)なので余分な作業は発生しない。 + // [Codex round6 Fix-2] top-level `if (capped) break` 제거. + // git 보고 레코드 리스트는 유한하므로 break 단락이 불필요한데, 오히려 위험하다: + // 비민감 링크가 cap 을 트립한 뒤 SENSITIVE 레코드(.ssh/ 등)가 이어지면, break 하면 + // 그 sensitive skip 이 captureIgnoredBaseline 의 fail-closed 검사에 도달하지 못한다(fail-OPEN). + // walk 내부의 `if (capped) return` 가드는 그대로 유지 — unbounded traversal 방지 위해 필수. + // cap 후 dir 레코드가 와도 walk(dir) 는 시작에서 즉시 return(bounded)이라 여분 작업이 없다. for (const e of ignored) { const rel = e.replace(/\\/g, '/') if (rel.endsWith('/')) { @@ -346,12 +345,12 @@ export async function collectIgnoredChanges( // [Codex round5 Fix-3] baselineSymlinkPaths = reason==='symlink' 만 — 'unclassified' 제외. // 'unclassified' 는 lstat-fail 로 실제 타입 불명. restore sweep 에서 확정 non-symlink 인지 // 알 수 없으므로 삭제 대상에서 제외해야 한다(파괴적 오동작 방지). - // [Codex round6 Fix-3 — DOCUMENT] 'unclassified' を created-whitelist から外さない理由: - // lstat が失敗した経路は「空のディレクトリだったのか、pre-existing なファイルだったのか」判定不能。 - // agent が後でそこにファイルを作った場合、強制削除すると pre-existing データを破壊する可能性がある。 - // これは round5 で起きた破壊的バグ(sweep が実ディレクトリを削除)と同クラスのリスク。 - // よって 'unclassified' は 'read-failed'/'over-cap' と同様に保守的 whitelist(強制 rollback しない)扱いとし、 - // unrestorable として表面化する(collect 側)。 + // [Codex round6 Fix-3 — DOCUMENT] 'unclassified' 를 created-whitelist 에서 제외하지 않는 이유: + // lstat 실패 경로는 "빈 디렉터리였는지, pre-existing 파일이었는지" 판정 불가. + // agent 가 그 자리에 파일을 만들었을 때 강제 삭제하면 pre-existing 데이터를 파괴할 수 있다 + // (round5 파괴적 버그 = sweep 이 실 디렉터리를 삭제 와 동일 클래스 리스크). + // 따라서 'unclassified' 는 'read-failed'/'over-cap' 와 같이 보수적 whitelist(강제 rollback 안 함)로 두고, + // unrestorable 로 표면화한다(collect 측). const baselineSymlinkPaths = new Set( baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), ) @@ -526,11 +525,11 @@ export async function restoreIgnoredBaseline( // restore sweep 은 "symlink 였는데 지금 non-symlink = 치환됨"을 판정한다. // 'unclassified' 는 baseline 시점 타입 불명이므로 sweep 대상에서 제외해야 한다. // 'unclassified' 경로를 sweep 에 포함하면 실제 디렉터리를 파괴하는 오동작이 발생한다. - // [Codex round6 Fix-3 — DOCUMENT] 'unclassified' を whitelist から外して強制 rollback しない理由: - // lstat-fail の経路は baseline 時点でのタイプ(symlink/dir/file)が確定できない。 - // 強制削除すると pre-existing なファイル/ディレクトリを破壊するリスクがある(round5 破壊的バグと同クラス)。 - // 'read-failed'/'over-cap' と同様に保守的 whitelist とし、unrestorable として表面化する。 - // 確定した symlink('symlink' reason)のみが sweep 対象になる — これが安全に削除できると証明された唯一のケース。 + // [Codex round6 Fix-3 — DOCUMENT] 'unclassified' 를 whitelist 에서 빼서 강제 rollback 하지 않는 이유: + // lstat-fail 경로는 baseline 시점 타입(symlink/dir/file)을 확정할 수 없다. + // 강제 삭제하면 pre-existing 파일/디렉터리를 파괴할 위험(round5 파괴적 버그와 동일 클래스). + // 'read-failed'/'over-cap' 와 같이 보수적 whitelist 로 두고 unrestorable 로 표면화한다. + // 확정된 symlink('symlink' reason)만 sweep 대상 — 안전히 삭제 가능하다고 증명된 유일한 케이스. const baselineSymlinkPaths = new Set( baseline.skipped.filter((s) => s.reason === 'symlink').map((s) => s.path), )