---
type: note
kind: Recipe
title: 에이전트 코딩 환경 — 워크트리·포트·자격증명
description: 병렬 에이전트를 돌릴 때 워크트리 위치·포트·gitignore 파일·토큰 범위를 결정적으로 고정하는 레시피.
tags: ['claude-code', 'git-worktree', 'hooks', 'dotfiles', 'security']
status: release
ctime: 2026-09-01
mtime: 2026-09-01
generated: { by: claude/opus-5, at: 2026-09-01T00:00:00Z }
sources:
  - id: domenic-agentic-setup
    title: "domenic.me — My Agentic Coding Setup, July 2026"
---

**언제** — 워크트리를 여럿 띄웠는데 서로의 서버를 죽이거나, 의존성 검증이 거짓 통과하거나, 새 워크트리에서 첫 명령이 실패할 때.

먼저 세 갈래를 정한다. 이게 무엇을 채택할지 결정한다.

| 질문 | 아니오 | 예 |
|---|---|---|
| 24시간 켜진 별도 호스트가 있나 | R8 폐기 | R8까지 |
| 폰·외부 머신에서 세션을 이어받나 | R4를 localhost 포트 격리로 축소 | R4를 프록시 + tailnet URL로 |
| 네이티브 API(Vision·Swift 바인딩)에 의존하나 | R5의 컨테이너 격리 가능 | 컨테이너 불가 — 호스트 직접 실행 + 토큰 축소로만 |

프로젝트 종류가 섞여 있으면 **격리 정책도 프로젝트별로 가른다.** 단일 정책을 강제하면 반드시 가장 약한 쪽으로 수렴한다.

---

## R1. 워크트리를 프로젝트 밖으로

기본 위치가 `<project>/.claude/worktrees/<name>` 이라 JS 생태계와 충돌한다. `node_modules` 해석이 상위로 올라가서 **의존성 삭제·다운그레이드 검증이 거짓 통과**하고, 재귀 순회하는 도구(`tsc` project references, vitest glob, `rg`, `find`)가 전부 워크트리로 들어간다.

`AGENTS.md` 에 "밖에 만들어"라고 쓰는 건 확률적 층에 맡기는 것이다. 훅으로 내린다 — `WorktreeCreate`/`WorktreeRemove` 는 기본 git 동작을 **증강이 아니라 대체**한다.

```bash
# ~/.claude/hooks/worktree.sh — settings.json 에 WorktreeCreate/Remove 로 등록
INPUT=$(cat)
EVENT=$(jq -r '.hook_event_name' <<<"$INPUT")
CWD=$(jq -r '.cwd' <<<"$INPUT")
ROOT="${WORKTREE_ROOT:-$HOME/.worktrees}"

create() {
  NAME=$(jq -r '.name' <<<"$INPUT")
  DIR="$ROOT/$(basename "$CWD")/$NAME"
  [ -d "$DIR" ] && { echo "$DIR"; exit 0; }   # 재사용
  BASE=$(git -C "$CWD" rev-parse --abbrev-ref HEAD)
  mkdir -p "$(dirname "$DIR")"
  git -C "$CWD" worktree add -b "wt/$NAME" "$DIR" "$BASE" >&2
  setup "$CWD" "$DIR" >&2       # R2·R3 이 여기 들어간다
  echo "$DIR"                   # 읽히는 유일한 stdout
}
```

**확인**

```bash
echo '{"hook_event_name":"WorktreeCreate","cwd":"'$PWD'","name":"probe"}' \
  | bash ~/.claude/hooks/worktree.sh          # 경로 한 줄만 나와야 한다
cd ~/.worktrees/<project>/probe && node -e "console.log(require.resolve('some-dep'))"
```

두 번째가 워크트리 밖 경로를 뱉으면 격리가 안 된 것이다.

**함정** — **stdout 오염이 곧 실패다.** `git worktree add` 도 stdout 에 쓰므로 부가 출력은 전부 `>&2`, 진행 표시가 필요하면 `/dev/tty`. 그리고 다른 훅과 달리 `WorktreeCreate` 는 **비영 종료 = 생성 중단**이고 JSON 반환도 못 한다(stdout 이 경로 전용이라). 훅은 `settings.local.json` 이 아니라 `settings.json` 에 둔다.

---

## R2. 워크트리별 결정적 포트

브랜치명을 해시해 고정한다. 같은 브랜치는 항상 같은 포트라 재현되고 URL 을 기억할 수 있다.

```bash
hash_port() {
  local n; n=$(printf '%s' "$1" | shasum | tr -dc '0-9' | head -c 6)
  echo $(( (10#$n % 6000) + 3100 ))
}
setup() {
  local PORT; PORT=$(hash_port "$(basename "$2")")
  printf 'PORT=%s\nVITE_PORT=%s\n' "$PORT" "$PORT" >> "$2/.env.local"
}
```

**함정** — macOS 엔 `md5sum` 이 없다(`md5` 다). 리눅스 기준 스크립트를 복사하면 여기서 깨지므로 `shasum`·`cksum` 을 쓴다. `10#$n` 은 선행 0을 8진수로 읽는 걸 막는다 — 빼면 간헐적으로 죽는다. 해시 충돌은 드물지만 **결정적이라 한 번 나면 그 브랜치쌍은 영원히 충돌한다.**

---

## R3. gitignore 된 파일 승계

새 워크트리엔 `.env`, 로컬 인증서, 빌드 캐시가 없어서 에이전트가 첫 명령에서 실패하고 원인을 추측하다 세션 절반을 태운다. 승계 목록을 리포에 선언하고 훅이 읽게 한다.

```bash
# .worktreeinclude 에 .env / .env.local / .certs/ / .tool-versions
while IFS= read -r pat; do
  [ -z "$pat" ] && continue
  (cd "$SRC" && rsync -R "$pat" "$DIR/")     # cp --parents 는 GNU 전용
done < "$SRC/.worktreeinclude"
(cd "$DIR" && pnpm install --frozen-lockfile >&2)
```

**함정** — **심링크 금지.** `.env` 를 심링크하면 에이전트가 워크트리 안에서 값을 바꿨을 때 메인과 다른 워크트리 전부가 동시에 오염된다. 그리고 **승계 목록이 곧 유출 경로다** — 자격증명을 넣는 순간 워크트리 개수만큼 사본이 는다. `WorktreeRemove` 에서 확실히 지운다. 설치가 오래 걸리면 훅 `timeout` 을 늘린다(타임아웃 = 생성 실패).

---

## R4. 프리뷰 서버 규약

에이전트가 "확인해보세요"라고 줄 URL 이 실제로 열려야 하고 secure context 여야 한다 — `crypto.subtle`·클립보드·서비스 워커가 여기 걸린다.

- **로컬 전용**: `http://localhost:<port>` 는 이미 secure context 다. 필요한 건 R2 뿐. `AGENTS.md` 에 "`.env.local` 의 `PORT` 를 쓰고, 점유돼 있으면 죽이지 말고 보고하라"만 적는다.
- **외부 접근**: `0.0.0.0` 바인딩 + IP 직접 접근은 secure context 가 **아니다.** 프로젝트 `dev` 스크립트를 고쳐서 풀지 말 것 — 환경 고유 관례를 리포에 침투시키는 것이다. 래퍼 바이너리 하나를 만들고 하네스가 그걸 부르게 한다.

**함정** — 이 규약은 확률적 층에 얹혀 있다. 결정적으로 내리려면 `PreToolUse` 훅에서 dev 명령 패턴을 잡아 차단하거나 래퍼로 치환한다.

---

## R5. 자격증명 축소가 먼저다

VM 으로 파일시스템 경계를 긋고 그 안에 전권 토큰을 넣으면 **파일시스템은 격리되지만 자격증명은 격리되지 않는다.** 실제 폭발 반경은 VM 이 아니라 계정 전체다. 게다가 위협 모델을 사고(accident)로만 잡으면 진짜 리스크인 prompt injection 이 빠진다 — 이슈 본문·PR 코멘트·의존성 README 를 읽는 순간 외부 텍스트가 명령이 된다.

컨테이너를 "cost/benefit 미달"로 미룰 때 **비용이 거의 없는 것까지 같이 미루지 않는다.**

1. **토큰 축소 (30분, 1회).** 기본 OAuth 스코프 대신 fine-grained PAT. 대상 리포 명시 선택, Contents·PR·Issues 만 read/write, **Workflows 제외**(CI 정의를 못 고치게 — 가장 위험한 승격 경로), Administration 제외, 만료일 설정. 이것만으로 레포 삭제·다른 프로젝트 오염·CI 를 통한 시크릿 탈취가 사라진다.
2. **실행 격리.** 순수 웹/Node 만 devcontainer 안에서 `bypassPermissions`. 네이티브 의존은 컨테이너 불가(Apple Silicon 컨테이너는 리눅스 VM 위)라 권한 모드를 기본으로 유지한다.
3. **주입 표면 좁히기.** 신뢰할 수 없는 텍스트를 읽는 세션과 쓰기 권한을 가진 세션을 나눈다. 웹 페치와 `gh` 쓰기가 같은 세션에 있으면 그게 곧 exfiltration 경로다.

**확인** — `gh auth status` 로 스코프를 본다.

---

## R6. 사용자 전역 설정을 버전관리로

`settings.json`·훅·스킬·`AGENTS.md` 가 홈에 흩어져 이력도 백업도 없다. **머신이 하나면 chezmoi 는 과잉이지만, 이력과 백업은 머신 수와 무관한 가치다.** private 리포 하나 + 심링크로 끝난다.

**함정** — 하네스가 파일을 rename 으로 **대체**하면 심링크가 깨진다. `ls -l ~/.claude/` 로 주기적으로 확인한다. `.gitignore` 에 `**/*.local.json`·`.env*`·`*credentials*` 를 넣고 첫 커밋 전 `git diff --cached` 를 눈으로 훑는다.

---

## R7. 설계 결정을 세션 밖으로

세션 트랜스크립트가 어떤 설계 결정의 유일한 기록인 경우가 있다. 이걸 "세션 히스토리를 백업하고 싶다"는 도구 문제로 프레이밍하면 틀린다 — 트랜스크립트는 신호 대 잡음비가 최악이고 백업해도 참조 대상이 안 된다. 진짜 문제는 **결정을 어디에 커밋하느냐**다.

`AGENTS.md` 에: 기각한 대안이 있었다면 코드 커밋과 **같은 커밋에** `docs/decisions/NNNN-<slug>.md` 를 추가한다. 맥락 / 결정 / 기각한 대안과 이유 / 결과, 한 화면 이내, 대화를 옮기지 말고 결론만.

**확인** — 나중에 "왜 이렇게 했지?"를 물었을 때 리포 안에서 답이 나오면 성공. `git log` 와 트랜스크립트를 뒤져야 하면 실패.

**함정** — 조건 없이 시키면 사소한 것까지 쓴다. "기각한 대안이 있었을 때만"을 명시한다.

---

## R8. 원격 호스트 (조건부)

Tailscale 로 SSH·HTTPS 를 호스트명으로 열고, **HTTPS 인증서로 secure context 를 확보**한다 — 포트포워딩 대신 이걸 택하는 실질적 이유가 그것이다. 랩탑을 닫아도 지속되려면 하네스의 원격 세션 모드를 쓴다(tmux 는 최후 수단). 머신이 둘 이상이 됐으므로 여기서는 chezmoi 가 정당화된다.

**함정 — 병렬화의 진짜 병목.** 워크트리와 포트를 아무리 격리해도 상한은 **사람의 diff 리뷰 대역폭**이다. 4개 동시 실행은 4개의 리뷰 큐를 만들 뿐이다. 병렬도를 올리기 전에 리뷰 처리량을 먼저 잰다.

---

## 실행 순서

각 단계가 독립적으로 가치를 내므로 중간에 멈춰도 된다.

1. **R6 (dotfiles 리포)** — 나머지 전부의 저장소가 된다
2. **R5-1 (토큰 축소)** — 30분·1회·효과 최대. 격리 설계를 기다릴 이유가 없다
3. **R1 (워크트리 위치)** — 훅 하나. 되면 R2·R3 은 같은 스크립트에 추가하는 것
4. R2·R3 — R1 의 `setup()` 채우기
5. R7 → R4 → R5-2 → R8

**유통기한**: R1 의 절반은 언젠가 설정 한 줄로 대체된다(기본값 문제라서). R2·R3·R5·R7 은 도구가 아니라 워크플로 설계 문제라 대체되지 않는다.
