훅: 파일은 최신인데 설정은 옛날이었다
맥 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/config가Include로 불러옴 - 사내 호스트 정보가 개인 레포로 새는 경로를 구조적으로 차단
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에 기록 - 크론잡이 그
.env를source→ 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 인증 팝업
- 첫 시도 —
op를timeout으로 감싸 멈춤 방지 - 그게 문제를 만들었음
| 프로세스 | 코드 서명 | 결과 |
|---|---|---|
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/.gitconfig는user.name/email을 프로파일별~/.gitconfig.local이include로 공급 - 따라서 공유 파일에
[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에 넣음 - 자동화에서 감지되지 않는 고장은 없는 것보다 나쁘다 — 조용히 틀린 상태로 며칠을 보내게 됨