AI

pull 은 됐는데 설정이 반만 적용된다 — 맥 4대 dotfiles 를 무인 동기화하기

  • #AI
  • #Claude Code
  • #셸스크립트
  • #자동화
  • #launchd

훅: 파일은 최신인데 설정은 옛날이었다

맥 4대(회사 2 / 개인 2)를 오가며 쓰는 상황

  • 한쪽에서 고친 Claude Code 규칙이 다른 쪽엔 없음
  • 새 머신 세팅 — zsh, git, ssh, 폰트, 플러그인을 기억에 의존해 복구
  • 회사 머신에만 있어야 할 사내 서버 SSH 설정이 개인 레포로 갈 뻔

Git 레포 하나를 단일 진실 공급원으로 두고 심링크로 각 머신이 그 파일을 보게 했다. 그런데 pull 이 성공해도 설정이 반영되지 않는 경우가 계속 나왔다.

원인은 옮겨지는 것과 옮겨지지 않는 것이 섞여 있었다는 데 있었다.

대상 pull 로 전파되나
파일 내용 (CLAUDE.md, .zshrc) 된다
심볼릭 링크 안 된다 — 링크는 내용이 아니다
skip-worktree 인덱스 플래그 안 된다 — 커밋 대상이 아니다
LaunchAgent 등록 주기 안 된다 — launchd 가 들고 있다

“최신 파일을 받았다” 와 “설정이 적용됐다” 는 다른 사건이다.

  • 조치 — pull 성공 시 install.sh 를 자동 실행해 시스템 상태까지 맞춤
  • 이 구분이 이후 설계 전체를 관통

구조

3층으로 나눴다.

Git 레포 (단일 진실 공급원)  ── 108 파일
   │  install.sh · 573줄 · 멱등
머신 (심링크 + LaunchAgent + 인덱스 플래그)
   │  dotfiles-sync.sh · 60초 주기
자동 동기화 · 드리프트 감지 · AI 자가 치유

프로파일 분기

머신 이름(scutil --get ComputerName)으로 프로파일을 판별

{
  "회사 노트북":  { "profile": "company-mac", "email": "me@company.example" },
  "집 Mac mini":  { "profile": "mac-mini",    "email": "me@personal.example" }
}

프로파일별로 갈리는 것

대상 갈리는 이유
gitconfig.local 회사 / 개인 계정 이메일이 다름
Brewfile 머신마다 필요한 패키지가 다름
skills/ 9개 Spring·프론트 배포 등 사내 워크플로우는 회사 머신에만
settings.local.json 플러그인 on/off 를 머신별로 override
  • 처음엔 hostname=profile 텍스트 파일이었음
  • 문제 — macOS 컴퓨터 이름에 비분리 공백(NBSP, U+00A0)이 섞여 grep 오매칭
  • 조치 — JSON + python3 파싱으로 교체

사내 서버 접속 정보는 레포에 아예 넣지 않았다.

  • ~/.ssh/config.work (git 미추적)로 분리
  • 공유 ssh/configInclude 로 불러옴
  • 사내 호스트 정보가 개인 레포로 새는 경로를 구조적으로 차단

1분 주기 잡의 비용 끊기

  • LaunchAgent 가 60초마다 실행 → 대부분은 “받을 게 없는” 실행
  • fetch 는 오브젝트를 받고 ref 를 쓰지만, ls-remote 는 원격 해시만 읽고 끝남
remote_head=$(git ls-remote origin refs/heads/main | cut -f1)
[[ "$remote_head" == "$(git rev-parse main)" ]] && exit 0   # 변경 없음 → 종료
git fetch -q origin    # 해시가 다를 때만 실제 fetch

문제 1: install.sh 가 자기 자신을 죽였다

증상

  • pull 성공 후 install.sh 호출 → 스크립트가 중간에 조용히 중단
  • 로그가 특정 지점에서 멈춤

원인

  • install.sh 는 자기를 실행한 LaunchAgent(dotfiles-sync)를 launchctl unload 함 — 등록 주기를 갱신하려고
  • launchd 는 unload 시 그 잡의 프로세스 그룹을 통째로 죽인다
  • 즉 install.sh 가 자기를 죽인 것

틀린 시도

  • setsid / nohup 으로 분리 시도 → 소용없음
  • 이유 — 같은 프로세스 그룹이라 함께 죽음

조치 — launchd 에게 별도 잡으로 위임

launchctl submit -l "$INSTALL_JOB" -o "$INSTALL_LOG" -e "$INSTALL_LOG" \
  -- /bin/bash -lc "PATH=/opt/homebrew/bin:... CLAUDE_SYNC_INSTALL=1 bash '$DOTFILES/install.sh'"
  • launchctl submit 으로 제출하면 호출자와 수명이 분리돼 살아남음

여기서 파생된 두 번째 문제

  • launchctl submit 은 plist 의 EnvironmentVariables 를 물려주지 않음
  • PATH 를 /usr/bin:/bin:/usr/sbin:/sbin 로만 줌
  • 그 상태에서 command -v brew 실패 → “Homebrew 미설치” 로 오판
  • 결과 — 설치 스크립트를 통째로 내려받아 실행하려 함 (무인이라 sudo 요구하며 실패)
  • 조치 — PATH 를 명시적으로 전달

무인 실행 환경은 대화형 셸과 다른 세계다.

문제 2: --allowedTools 는 강제되지 않았다

가장 신경 쓴 부분.

맥락

  • pull 이 충돌로 막히면 사람이 개입할 때까지 그 머신은 동기화 정지
  • 그래서 Claude Code 를 헤드리스(claude -p)로 무인 호출해 충돌 해소
  • 문제 — 이 세션은 Bash 를 씀. git 조작이 목적이라 뺄 수 없는데, 그러면 임의 셸 명령이 가능

첫 접근

  • 프롬프트에 금지 목록 작성
  • --allowedTools "Bash(git status:*)" 로 범위 지정

실측 결과

  • 범위 지정이 강제되지 않음 — 허용목록 밖 명령(cat 등)이 그대로 실행
  • 반면 --disallowedTools 는 실제로 막힘
  • 즉 프롬프트의 금지 목록은 모델에 대한 요청이지 기술적 차단이 아니었음

조치 — PreToolUse 훅으로 이전

  • 훅은 실제로 강제됨 (실측: whoami 차단 / echo 통과 확인)
  • 매 Bash 호출의 명령 문자열을 검사해 exit 2 로 거부
# 되돌릴 수 없는 git 파괴 명령
if printf '%s' "$cmd" | grep -qiE \
  'push[[:space:]]+.*(--force|-f)|reset[[:space:]]+--hard|clean[[:space:]]+-[a-zA-Z]*f|branch[[:space:]]+-D'; then
  echo "guard: 되돌릴 수 없는 파괴적 git 명령은 금지 — $cmd" >&2
  exit 2
fi

차단 범주 4개

범주
되돌릴 수 없는 git push --force, reset --hard, clean -fd, filter-branch
시크릿·자격증명 .ssh/, *.pem, .env, .aws, 키체인, 각종 토큰 이름
외부 전송 curl, wget, nc, scp, rsync, ssh
시스템 변경 sudo, rm -rf, chmod 777, launchctl unload, crontab
  • 화이트리스트가 아니라 블랙리스트를 택함
  • 이유 — git 하위 명령이 너무 다양해 허용목록 유지가 더 자주 깨짐

차단은 명령어 이름이 아니라 명령 문자열 전체를 봄

명령 결과
cat README.md, git status 통과 (exit 0)
cat ~/.ssh/config 차단 (exit 2) — .ssh/ 패턴
  • 즉 “cat 은 허용” 이 아니라 “시크릿 경로를 건드리지 않는 cat 은 허용”

훅 자체를 단위 검증할 수 있게 만듦

echo '{"tool_name":"Bash","tool_input":{"command":"cat ~/.ssh/config"}}' | bash scripts/sync-ai-guard.sh
# → guard: 시크릿·자격증명 접근은 금지 / exit 2

무인 실행이라 안전장치 3개 추가

장치 이유
호출 간격 제한 1시간 실패가 반복돼도 모델 호출이 쌓이지 않게
실행 시간 제한 180초 멎은 세션이 launchd 슬롯을 잡고 있지 않게
결과 보고 텔레그램 + macOS 알림 무인 편집이 조용히 지나가지 않게
  • 1Password 서비스 계정 토큰은 일부러 주입하지 않음
  • 이유 — 전 프로젝트 시크릿을 여는 자격증명이고 git 동기화엔 쓸 일 없음

문제 3: USER 하나가 없어서 OAuth 가 실패했다

  • 증상 — 무인 AI 세션이 Failed to authenticate: OAuth session expired and could not be refreshed 로 죽음
  • 혼란스러웠던 점 — 키체인 읽기 자체는 되고 있어 인증 저장소 문제로 보이지 않음

원인

  • launchd 는 PATH 는 주지만 USER 를 주지 않음
  • 그 상태의 claude -p 가 OAuth 갱신에 실패
  • LOGNAME 만으론 안 되고 USER 하나면 해결
export USER="${USER:-$(id -un)}"
  • plist 가 아니라 스크립트에 넣은 이유 — plist 를 고치면 머신마다 launchctl 재로드가 필요하지만, 스크립트는 pull 만으로 전 머신에 퍼짐

문제 4: API 키가 새어 크론잡 9개가 전멸

사건

  • 유튜브 요약 크론잡 9개가 동시에 401 로 죽음

추적

  • install.sh 가 1Password 로 .env 생성 시 op run -- env 출력을 grep '^[A-Z_]+=.' 로만 필터
  • 이 패턴은 대문자 변수를 전부 통과시킴
  • Claude Code 세션 안에서 install.sh 를 돌리면 그 세션의 ANTHROPIC_API_KEY 까지 .env 에 기록
  • 크론잡이 그 .envsource → claude CLI 가 구독 로그인 대신 그 키를 우선 → 전부 401

조치 — 템플릿에 선언된 키만 통과

_env_keys=$(grep -oE '^[A-Z_]+=' "$ENV_TEMPLATE" | tr -d '=' | sort -u | paste -sd'|' -)
op run --env-file="$ENV_TEMPLATE" --no-masking -- env | grep -E "^(${_env_keys})="

허용 패턴이 넓으면 허용목록이 아니다. ^[A-Z_]+= 는 필터처럼 생겼지만 사실상 통과였다.

문제 5: 우회하려다 새 실패 모드를 만들었다

TCC 팝업

  • ~/Library/Application Support/ 는 macOS TCC 보호 영역
  • 터미널 실행 — 터미널 앱이 책임 주체라 한 번 허용하면 기억됨
  • launchd 무인 실행 — 주체가 익명의 /bin/bash 라 승인이 쌓이지 않고 매번 다시 물음
  • 조치 — 무인 경로에서는 이 링크 작업을 건너뛰고 사람이 실행할 때만 수행

1Password 인증 팝업

  • 첫 시도 — optimeout 으로 감싸 멈춤 방지
  • 그게 문제를 만들었음
프로세스 코드 서명 결과
op Apple 인증 (Developer ID: AgileBits) 통과
coreutils timeout adhoc (TeamIdentifier not set) 매번 승인 팝업
  • 무인 실행에는 그 팝업에 응답할 사람이 없음 → 영영 멈춤
  • 막으려던 문제가 되돌아옴

조치 — 무인 실행에서는 op 를 아예 호출하지 않음

  • 어차피 1Password 인증은 사람이 있어야 하므로 무인에서 .env 갱신은 불가능
  • 시도하지 않으면 팝업도 멈춤도 없음

무인 실행에서 “사람이 필요한 작업” 은 우회하지 말고 건너뛴다.

문제 6: 외부 도구가 공유 파일을 계속 더럽혔다

  • 증상 — 매일 pull 이 충돌로 막힘

원인 2가지

파일 누가 더럽히나
claude/settings.json 외부 도구가 머신 로컬 절대경로(/Users/...)가 박힌 훅을 자동 주입
git/.gitconfig ~/.gitconfig 가 이 파일의 심링크라 git config --global user.email 이 공유 파일에 [user] 블록을 씀

조치 — skip-worktree 로 로컬 변경만 무시

for _swt in claude/settings.json git/.gitconfig; do
  git update-index --skip-worktree "$_swt"
done
  • 파일은 계속 트래킹되므로 다른 머신은 정상적으로 최신본을 받음
  • git/.gitconfiguser.name/email 을 프로파일별 ~/.gitconfig.localinclude 로 공급
  • 따라서 공유 파일에 [user] 블록이 있으면 안 된다는 불변식이 생김
  • 이 불변식은 사람이 기억하는 대신 검증 스크립트에 넣음

드리프트 감지: “고쳤다” 를 검증으로 바꾸기

새 문제 — 자동화가 조용히 깨져도 알 수 없다. 심링크가 끊기거나 플래그가 풀려도 증상은 며칠 뒤에야 나타난다.

scripts/verify.sh 가 검사하는 것

  • 심링크 8개가 정확히 기대한 경로를 가리키는지 (존재 여부가 아니라 대상까지)
  • hostmap.json 에 현재 머신 매핑이 있는지
  • skip-worktree 플래그가 걸려 있는지
  • brew bundle 상태가 Brewfile 과 일치하는지

  • exit 0/1 반환 → 자동 동기화에 연결 → 드리프트 발견 시 macOS 알림

설계에서 신경 쓴 2가지

오탐 줄이기 — 링크된 git 워크트리에서는 skip-worktree 가 없는 게 정상

  • 이유 — 플래그는 인덱스에 저장되고 인덱스는 워크트리마다 별개
  • 판별 — 본 체크아웃은 .git 이 디렉터리, 링크된 워크트리는 .git 이 파일

진짜 원인 먼저 짚기 — 프로파일 이름을 잘못 주면 아래 심링크 검사가 전부 오탐

❌ 프로파일 디렉터리 없음: claude/profiles/company
   사용 가능: company-mac company-pro mac-air mac-mini
  • set -euo pipefail 을 일부러 쓰지 않음 — 검사 하나가 실패해도 끝까지 진행해 전체 그림을 봐야 함

결과

항목 수치
관리 머신 4대
커밋 220 (4개월) · PR 15건
관리 파일 108
셸 스크립트 약 1,000줄
자동화 컴포넌트 LaunchAgent 4종, PreToolUse 훅 2종

신규 머신 세팅 — 한 줄

CLAUDE_PROFILE=company bash <(curl -fsSL https://raw.githubusercontent.com/…/bootstrap.sh)
  • Homebrew 설치 → 레포 클론 → install.sh 실행이 한 번에

남는 교훈

무인 실행은 다른 실행 환경이다.

  • PATH, USER, 키체인 잠금, TCC 승인 주체, 코드 서명 검사가 전부 다름
  • 이 글의 문제 대부분이 여기서 나옴
  • “사람이 필요한 작업” 은 우회하려 하지 말고 건너뛰는 게 맞았음 — 우회 시도가 새 실패 모드를 만듦

프롬프트는 요청이고, 훅이 차단이다.

  • “이건 하지 마” 라고 쓰는 것과 실제로 못 하게 만드는 것은 다름
  • --allowedTools 범위 지정이 강제되지 않는 걸 실측으로 확인한 뒤에야 훅으로 이전
  • 자율 에이전트를 무인으로 돌린다면 경계는 프롬프트 밖에 있어야 함

실측 근거를 코드에 남긴다.

  • “이렇게 하면 안 된다” 만 쓰면 나중의 내가 다시 시도함
  • 무엇을 시도했고 왜 실패했는지를 함께 적음
# setsid/nohup 으로 떼어내도 같은 프로세스 그룹이라 함께 죽는다.
# launchctl submit 으로 launchd 에 별도 잡으로 위임하면 호출자와 무관해져 살아남는다.

불변식은 사람이 아니라 검증기가 지킨다.

  • “공유 gitconfig 에 [user] 블록이 있으면 안 된다” 같은 규칙은 반드시 잊힘
  • 그래서 verify.sh 에 넣음
  • 자동화에서 감지되지 않는 고장은 없는 것보다 나쁘다 — 조용히 틀린 상태로 며칠을 보내게 됨